Concepts
The mental model behind env-kernel, in the order you'll meet it.
The environment model — everything is data
A world is a WorldTemplate (Pydantic, extra="allow") split into three logical layers:
- Schema layer — type definitions:
entity_types,resource_types,relation_types,visibility_rules,spatial,temporal, lookuptables. - Rules layer — behavior:
actions(preconditions / resolution / effects),triggers,derived_rules,termination_conditions,domain_modules. - Viz layer — rendering hints, opaque to the kernel.
Plus an instance layer (entities, resource_holdings, initial_relations,
factions, adjacency) and optional subsystems (goals, skills, recipes, connectors,
cognitive/social/crowd config, runtime parameters).
The runtime object is a WorldState graph: entity types + entities, resource pools,
a relation graph, a spatial model (NoSpace / GridSpace / GraphSpace /
Continuous2DSpace), a temporal model, and registered subsystem modules.
build_world_state(schema) is the single canonical JSON→state builder (~30 focused
_apply_* section-appliers); load_world wraps it and returns (state, engine).
The time model
Two modes, selected by temporal.mode:
- Discrete (default). Fixed rounds with phases (
Phaseobjects, roles, initiative).engine.run()loops up tomax_rounds, one round at a time; physics ticks once per round with dt = 1. - Continuous.
temporal.mode == "continuous"builds aContinuousTemporalModel. Time is a float advanced by an event-driven clock: a heap-basedEventQueuepopsScheduledEvents; agent turns reschedule by their action'sduration; a recurringenvironment_intervaltick advances physics, property dynamics, and world events by the real elapseddt. The clock, not a round counter, drives evolution — agents stay turn-based, but fast and slow actions coexist on one timeline, so reaction speed becomes strategy.
Determinism under continuous time is protected in two ways: ScheduledEvent has a total
ordering (time → priority → entity_id → id), so float-collision ties never resolve
arbitrarily; and safety rails (MIN_ACTION_DURATION = 1e-9, DEFAULT_MAX_EVENTS =
1_000_000, max_time) force forward progress and guarantee termination.
Physics — coupled ODEs, "code is physics"
A template's physics block declares params, substeps, and variables. Each
integrated variable carries a rate expression — the right-hand side of an ODE
d(var)/dt = f(all vars, params, t) — and every variable's rate can reference every
other, so genuinely coupled systems (Lotka–Volterra predator/prey, SIR epidemics,
logistic growth, price discovery) are first-class. Integration is classic 4th-order
Runge–Kutta with configurable sub-stepping (capped at MAX_SUBSTEPS = 4096);
min/max clamp after each step. This is distinct from property_dynamics, which
handles independent per-property drift/cascade/spawn.
Rate expressions run through a safe evaluator (_CompiledExpr): a whitelisted-AST
numeric evaluator, not eval. It rejects attribute access, comprehensions, lambdas,
non-numeric constants, and unknown symbols at build time (PhysicsExprError).
Whitelisted functions: sin, cos, tan, exp, log, sqrt, abs, min, max, pow, floor, ceil,
tanh, sigmoid, sign, clamp; constants pi, e, tau; time symbol t.
Graceful degradation: numeric blow-ups (division by zero, domain errors, overflow,
non-finite results) never crash the sim — the step is skipped and a physics_error
change is emitted. Physics is an enhancement layer, not a failure mode.
Grounding — binding physics to the world
"Grounding" is the binding of abstract numeric state to the concrete entity graph:
EntitySource— a variable reads an aggregate of an entity property (reduce:sum | avg | mean | min | max | count). Source variables are algebraic (read-only); a variable may have arateor asource, never both. Non-finite reads are dropped at the source so they can't poison integration.EntityWriteback— a variable writes its value back onto entities.broadcastgives every matching entity the value;distributesplits it equally. This is how a singlepricebecomes every trader'sobserved_price, or aninfectedcount drives per-agent state.
Agents are grounded too: the world's rules markdown is injected into every agent's
perception as the world_brief, anchoring decisions in the declared premise and win
conditions.
Perception — what an agent sees
Each turn the engine builds a plain perception dict (no kernel imports needed to
read it) and passes it to decision_fn(entity_id, perception, valid_actions), where
valid_actions is a list of action-name strings. The visibility-filtered base
(visibility.py) always contains self (the agent's own entity as a dict), round,
phase, visible_entities, visible_resources, visible_relations, location,
active_world_events, and faction. The engine then layers optional keys on top when
the relevant subsystem is active: world_brief, incoming_messages, domain_data,
cognitive_state, crowd_trends, time_context, your_role / visible_roles /
teammates, open_polls, your_recent_actions, trade_history, and (in richer
worlds) inventory, goals, skills, location_info, and more.
Outcomes — resolution archetypes and termination
There is no single "OutcomeSpec" object; outcomes live at two levels.
Action outcomes are decided by resolution archetypes. An action declares
resolution_archetype + resolution_params; the archetype returns a
ResolutionResult(success, magnitude, narrative, details), and the action's
effects_on_success / failure / partial are then applied. The 13 built-ins:
| Archetype | Semantics |
|---|---|
deterministic |
Always succeeds |
skill_check |
Probabilistic skill check |
contest |
Opposed actor-vs-target contest |
deterministic_math |
Success from a computed expression |
voting |
Vote tally |
evidence_chain |
Evidence accumulation |
order_book |
Order-book matching |
cpmm |
Constant-product market maker |
market_impact |
Price-impact trade |
auction_sealed_first / auction_sealed_second |
Sealed-bid auctions (first/second price) |
auction_english / auction_dutch |
Open ascending / descending auctions |
Custom archetypes register with @resolution("name").
Game outcomes are termination conditions with paired winner resolvers. A
TerminationSpec names a check_type (17 built-ins, from expr and round_limit to
last_one_standing, bankruptcy, faction_win, vote_threshold); when one fires,
resolve_winner computes {"winner_id": ..., "winner_name": ...}. Winner resolvers
ship for the competitive check types and are pluggable via register_winner_resolver.
The expression language
Guards, effects, and terminations share one safe expression grammar
(predicates.evaluate / predicates.resolve):
$actor.gold >= 100 && $count(player, alive) > 1
$-expressions resolve against live state: $actor.x, $target.x, $random(...),
$count(...), $lookup against schema tables. Every effect value, precondition, and
termination expression can be grounded this way.
Authoring workflow
- Emit a JSON/dict
WorldTemplate— an env-builder agent never writes Python. - Reference genre mechanics by string name: registered domain modules, archetypes, effects.
- Where the built-ins run out, extend with zero kernel edits:
@effect,@precondition,@resolution,@phase,@termination,register_winner_resolver, a customKernelModule(state.register_module), or a customDomainModule(DomainModuleRegistry.register). Files inkernel_primitives/*.py(repo + env-var directory) are auto-imported at startup, so downstream apps drop in primitives without touching kernel imports. - Iterate:
raw JSON → compile_template → lint → smoke_test → real-LLM playtest → packinto a.simworldenv package.
Domain modules
A DomainModule bundles game-genre physics as a reusable unit, with hooks across the
whole action lifecycle: tick, validate_action, filter_valid_actions,
modify_resolution, post_resolution, get_perception_data, get_constraints, plus
declared required_properties and custom_actions. Modules are discovered through the
singleton DomainModuleRegistry and managed by DomainModuleManager. Two full
implementations ship — PredictionMarketModule and SecuritiesTradingModule — with
Economic/Political/Ecological/Health stubs marking the intended library.
Determinism
A first-class design promise, not a side effect:
- One seeded
random.Random(self.seed)is shared into property dynamics, world events, and domain modules (viareseed(rng)), so every draw is reproducible. - Unseeded runs mint a seed from entropy and record it on
engine.seed— exact replay is always possible. - Everything serializes:
to_dict/from_dictthroughout (state, physics, event queue), tested round-trip (tests/test_state_roundtrip.py), with determinism itself covered bytests/test_determinism.py. replayreproduces a run as a step-by-stepReplayTrace.