docs / env-kernel / examples

Examples

Runnable recipes. All examples mirror the repo's README and test suite (tests/test_engine.py, tests/test_physics_integration.py, et al.).

Minimal build-and-run

from fg_env_kernel import load_world

state, engine = load_world(my_world_definition, seed=42, decision_fn=my_agent_brain)
engine.run()

Same template + same seed + same decision_fn = byte-identical run, every time.

Registering a custom effect verb

New mechanics never require editing the engine — register a handler and reference it by name from any template's effect specs:

Handlers receive (ctx, spec) — an EffectContext plus the effect-spec dict (target / field / value / resource …), mirroring the kernel's own handlers in composition.py:

from fg_env_kernel import effect, EffectContext

@effect("grant_gold")
def grant_gold(ctx: EffectContext, spec: dict):
    amount = ctx.resolve(spec.get("value", 0)) or 0
    ctx.actor.set("gold", (ctx.actor.get("gold") or 0) + amount)
    ctx.record({"type": "grant_gold", "entity": ctx.actor.id, "amount": amount})

EffectContext carries state, actor, target, params, result (the ResolutionResult), rng, emit, and changes; use ctx.resolve(value) to evaluate $-expressions and ctx.record(change) to log changes for the round transcript. Drop such files into kernel_primitives/*.py and they are auto-imported at startup.

Programmatic world with contest resolution

Two warriors, an opposed-strength attack, and a deterministic rest — built directly against the state API (full walkthrough in Getting started):

state.register_action(ActionDefinition(
    name="attack", description="Strike an opponent (opposed strength check)",
    actor_type="warrior", target_type="warrior",
    preconditions=[Precondition(subject="actor", operator=Operator.GTE,
                                field="stamina", value=10)],
    resolution_archetype="contest",
    resolution_params={"attacker_property": "strength", "defender_property": "strength"},
    effects_on_success=[
        Effect(target="target", operation=EffectOperation.SUBTRACT, field="health", value=20),
        Effect(target="actor",  operation=EffectOperation.SUBTRACT, field="stamina", value=10),
    ],
    effects_on_failure=[
        Effect(target="actor", operation=EffectOperation.SUBTRACT, field="stamina", value=5),
    ],
))

engine = SimulationEngine(state=state, decision_fn=always_rest, max_rounds=3, seed=42)
result = engine.run()
assert result.get_entity("w1").get("stamina") == 95.0   # 50 + 15 * 3

The contest archetype rolls the opposed check with the engine's seeded RNG; the declared effect lists apply based on its ResolutionResult.

Declarative physics: logistic growth with writeback

A herd whose size follows the logistic ODE, integrated by RK4 each round and written back onto the entity so agents perceive it:

from fg_env_kernel import load_world
from fg_env_kernel.action import ActionInstance

schema = {
    "name": "ecosystem",
    "entity_types": [
        {"name": "ranger", "role": "agent", "properties": []},
        {"name": "herd", "role": "object",
         "properties": [{"name": "size", "type": "float", "default": 10}]},
    ],
    "entities": [
        {"id": "r1", "name": "Ranger", "entity_type": "ranger", "properties": {}},
        {"id": "h1", "name": "Herd",   "entity_type": "herd",   "properties": {"size": 10}},
    ],
    "actions": [{"name": "observe", "actor_type": "ranger",
                 "resolution_archetype": "deterministic"}],
    "temporal": {"phases": [{"name": "action"}], "mode": "discrete"},
    "physics": {
        "params": {"r": 0.8, "K": 1000.0},
        "substeps": 8,
        "variables": [
            {"name": "population", "value": 10.0,
             "rate": "r * population * (1 - population / K)", "min": 0.0,
             "writeback": {"entity_type": "herd", "property": "size",
                           "mode": "broadcast"}},
        ],
    },
}

def idle(entity_id, perception, valid_actions):
    # valid_actions is a list of action-name strings
    if valid_actions:
        return ActionInstance(action_name=valid_actions[0], actor_id=entity_id)
    return None

state, engine = load_world(schema, seed=1, decision_fn=idle)
engine.max_rounds = 30
engine.run()

pop = state.physics.values["population"]           # > 100 — logistic growth occurred
assert state.get_entity("h1").get("size") == pop   # writeback grounded onto the entity

Coupled ODEs: predator/prey

Lotka–Volterra in four lines of template — each rate references the other variable, so the system is genuinely coupled:

"physics": {
    "params": {"alpha": 1.1, "beta": 0.4, "delta": 0.1, "gamma": 0.4},
    "variables": [
        {"name": "prey", "value": 10, "rate": "alpha*prey - beta*prey*pred", "min": 0},
        {"name": "pred", "value": 5,  "rate": "delta*prey*pred - gamma*pred", "min": 0}
    ]
}

Rate expressions are compiled by the safe whitelisted-AST evaluator — attribute access, lambdas, and unknown symbols are rejected at build time with PhysicsExprError, and runtime numeric blow-ups skip the step and emit a physics_error change instead of crashing.

Continuous-time mode

Switch the temporal mode and give actions durations; the event-driven clock does the rest:

"temporal": {"mode": "continuous", "phases": [{"name": "action"}]},
"actions": [
    {"name": "quick_jab",  "actor_type": "fighter", "duration": 0.5, ...},
    {"name": "haymaker",   "actor_type": "fighter", "duration": 3.0, ...},
]

The loader builds a ContinuousTemporalModel: agent turns reschedule after each action's duration, and recurring environment ticks advance physics and property dynamics by the real elapsed dt — a fast fighter simply acts more often. Event ordering is deterministically total (time → priority → entity_id → id), so replays stay exact even under float-time collisions.

Custom termination condition

Built-in check_types cover most games; when they don't, register your own — and a winner resolver to go with it:

Both are called as fn(state, params, rng), where params is the condition's params dict (not the condition object) — the same convention the built-ins and tests/test_termination.py use:

from fg_env_kernel import termination_decorator, termination

@termination_decorator("gold_hoard")
def gold_hoard(state, params, rng) -> bool:
    threshold = params.get("threshold", 1000)
    return any(state.resources["gold"].holdings.get(e.id, 0) >= threshold
               for e in state.entities.values())

def richest_wins(state, params, rng):
    holdings = state.resources["gold"].holdings
    winner_id = max(holdings, key=holdings.get)
    return {"winner_id": winner_id,
            "winner_name": state.get_entity(winner_id).name}

termination.register_winner_resolver("gold_hoard", richest_wins)

Templates then reference it declaratively. Check parameters nest under "params", and the validated path (TerminationSpec) requires a name:

"termination_conditions": [
    {"check_type": "gold_hoard", "name": "gold_hoard",
     "params": {"threshold": 1000}},
]

Expression-based conditions need no code at all:

"termination_conditions": [
    {"check_type": "expr", "name": "last_warrior",
     "params": {"expr": "$count(warrior, alive) <= 1"}},
    {"check_type": "round_limit", "name": "round_cap",
     "params": {"rounds": 50}},
]

Validate before you ship

The authoring loop for a new environment:

from fg_env_kernel import compile_template

result = compile_template(raw_json, smoke=True, smoke_rounds=20)
# result: CompileResult — lint issues + a SmokeReport from a stub-agent dry run

Then playtest with a real LLM decision_fn, and package with pack_env into a .simworld archive (or from the CLI: fg-env-kernel compile, fg-env-kernel pack). Use replay to get a deterministic step-by-step trace of any run.