AsyncWorld is the central simulation coordinator in Archetype's core layer,
but beginner-facing scripts should usually interact with a RuntimeWorld
handle from ArchetypeRuntime. RuntimeWorld is the governed script API;
AsyncWorld is the underlying engine object that owns entity-archetype
mappings, mutation caches, the parallel tick cycle, and lifecycle hooks.
Creating a World¶
Recommended for scripts:
from archetype import ArchetypeRuntime
async with ArchetypeRuntime() as runtime:
world = runtime.world("my-sim")
RuntimeWorld vs AsyncWorld¶
ArchetypeRuntime.world(...) returns RuntimeWorld, not a raw AsyncWorld.
That distinction is intentional:
RuntimeWorldis the public script surface. Its entity and processor mutations (spawn,despawn,update,add_components,remove_components,add_processor,remove_processor) route throughCommandServiceandCommandBroker, so they honor RBAC and appear in broker history.RuntimeWorld.command_history()reads that audit trail back through the read path.RuntimeWorld.as_actor(ctx)returns another handle to the same logical world, bound to a differentActorCtx, without creating a new world or storage backend.AsyncWorldremains the direct engine API. Calling it directly may bypass broker semantics, which is appropriate for engine and service-layer code.
The runtime intentionally keeps a few script-scaffolding operations immediate instead of brokered:
runtime.world(...)/world.as_actor(...)world.resources.insert(...)/world.resources.remove(...)world.add_hook(...)/world.remove_hook(...)
The rest of this page describes the engine-level AsyncWorld behavior that
those runtime calls ultimately drive.
Lower-level via the service layer:
from archetype.core.config import WorldConfig
from archetype.app.container import ServiceContainer
container = ServiceContainer()
world = await container.world_service.create_world(WorldConfig(name="my-sim"))
Direct construction is core-internal / advanced:
from archetype.core.aio.async_world import AsyncWorld
from archetype.core.config import WorldConfig
world = AsyncWorld(
world_config=WorldConfig(name="my-sim"),
querier=querier,
updater=updater,
system=system,
)
World Properties¶
| Property | Type | Description |
|---|---|---|
world_id |
UUID |
Unique identifier, set at creation |
name |
str |
Human-readable name |
tick |
int |
Current simulation tick (starts at 0) |
resources |
Resources |
Type-safe dependency injection container |
run_id |
str |
Current run identifier (set by run()) |
Entity Management¶
On RuntimeWorld, the corresponding public verbs are spawn, despawn,
update, add_components, and remove_components. Those calls still
materialize at tick boundaries; the sections below describe the underlying
AsyncWorld mechanics.
Creating Entities¶
entity_id = await world.create_entity([
Position(x=0, y=0),
Velocity(vx=1, vy=0),
])
Entities are not persisted immediately. They enter a spawn cache and are written to the archetype table at the start of the next step(). Deferring mutations to tick boundaries ensures that all processors within a single tick observe the same entity set.
Removing Entities¶
await world.remove_entity(entity_id)
Like spawns, removals are deferred. The entity is marked is_active=False during materialization.
Adding and Removing Components¶
# Add a component -- entity migrates to a new archetype
await world.add_components(entity_id, [Health(current=100, max_hp=100)])
# Remove a component type -- entity migrates back
await world.remove_components(entity_id, [Health])
Component mutations trigger archetype migration: the entity's row is marked inactive in the old archetype table and a new row (with carried-over field values) is spawned in the target archetype table.
Tick Lifecycle¶
Each call to step() executes one simulation tick:
1. `PreTick` hooks fire
2. For each archetype (in parallel):
a. Query previous state (from _live cache or store)
b. Materialize deferred mutations (spawns/despawns)
c. Execute matching processors in priority order
d. Persist updated DataFrame to store
3. Update _live snapshots
4. Increment tick counter
5. `PostTick` hooks fire
Running Multiple Ticks¶
from archetype.core.config import RunConfig
await world.run(RunConfig(num_steps=10))
This calls step() in a loop. Each run gets a unique run_id for storage isolation.
The _live Cache¶
_live is a dict[ArchetypeSignature, DataFrame] that holds the most recent processed DataFrame per archetype. It is the authoritative in-memory state of the world between ticks.
Why It Exists¶
The store is the durability layer, but reading from it between consecutive ticks is fragile. Each SimulationService.step() emits a fresh run_id, so store reads filtered by the current run_id miss rows written by earlier ticks. World forks exhibit the same issue: the cloned snapshot is persisted under a placeholder run_id and the next step queries under a different one.
_live fixes this (archetype#72). After all archetypes finish processing, step() updates _live with the output DataFrames filtered to active rows:
self._live = {
sig: df.where(col("is_active")) for sig, df in zip(sigs, results)
}
On subsequent ticks, _run_archetype checks _live first:
if self.tick > 0 and sig in self._live:
df = self._live[sig]
else:
df = await self.query_archetype(sig, ...)
The store read is only used for tick 0 (when there is no prior output) or for archetypes not yet in _live.
Mutation Internals¶
Spawn/Despawn Caches¶
_spawn_cache and _despawn_cache are dict[ArchetypeSignature, list]. Mutations accumulate during the interval between ticks and are materialized at the start of each archetype's processing in materialize_mutations().
Despawns are applied first. The method deduplicates entity IDs, then sets is_active=False on matching rows using when().otherwise():
df = df.with_column(
"is_active",
when(col("entity_id").is_in(entities_to_despawn), then=False)
.otherwise(col("is_active")),
)
Spawns are applied second. Duplicate spawns for the same entity are deduplicated with last-write-wins semantics -- a forward dict comprehension keeps the latest row per entity_id:
rows = list({row["entity_id"]: row for row in self._spawn_cache[sig]}.values())
The deduplicated rows are converted to a PyArrow table using the archetype's schema, then concatenated to the existing DataFrame.
Both caches are cleared after materialization.
Entity Migration¶
When add_components() or remove_components() changes an entity's component set, the entity migrates between archetype tables. The algorithm in _move_entity():
-
Fetch -- Read the entity's current row from
_live(or an empty DataFrame if_livehas no data for the old archetype). Filter to the target entity, materialize, take the latest tick row. -
Overlay -- Apply mutated component fields. For
add_components, the new component'sto_row_dict()overwrites matching keys. Forremove_components, no overlay is needed -- the row simply drops the removed component's columns when it enters the narrower archetype schema. -
Stamp -- Set housekeeping columns (
entity_id,tick,world_id,is_active=True). Therun_idis set to a placeholder ("") and the updater stamps the real value duringupdate().
After _move_entity returns the new row:
- The old entity is marked for despawn in the old archetype
- The new row is added to the spawn cache for the new archetype
_entity2sigis updated atomically
Lifecycle Hooks¶
Worlds expose typed lifecycle hooks for observability and integration glue. The canonical hook API and event catalogue are documented in Lifecycle Hooks.
Hooks are registered against dataclass event types from archetype.core.hooks.
add_hook returns an opaque HookHandle for removal, and handlers take a
single event argument:
from archetype.core.hooks import PostTick
async def log_tick(event: PostTick) -> None:
print(f"Tick {event.tick} complete")
handle = world.add_hook(PostTick, log_tick)
# ...later...
world.remove_hook(handle)
| Event | Payload fields | When |
|---|---|---|
PreTick |
world_id, tick |
Before any archetype runs in step() |
PostTick |
world_id, tick, results |
After all archetypes processed, _live refreshed, tick incremented |
OnSpawn |
world_id, entity_id, components |
After create_entity / spawn_reserved registers the entity |
OnDespawn |
world_id, entity_id |
After remove_entity cancels a pending spawn or queues a despawn row |
OnComponentAdded |
world_id, entity_id, components |
After add_components moves the entity to a new archetype |
OnComponentRemoved |
world_id, entity_id, component_types |
After remove_components moves the entity to a new archetype |
Payloads carry world_id: UUID, not the AsyncWorld instance itself. Handler
exceptions are logged at warning level and do not halt the tick.
Querying State¶
# Query a specific archetype
df = await world.query_archetype(sig, ticks=[5], entity_ids=[1, 2])
# Query by component types across all matching archetypes
df = await world.get_components([Position, Health], entity_ids=[1, 2])
get_components reads from _live, unions rows from every archetype whose signature is a superset of the requested types, and projects to the requested component schema.
Processors¶
Add or remove processors at runtime:
await world.add_processor(MovementProcessor())
await world.remove_processor(MovementProcessor)
See Processors and Systems for how processors are matched to archetypes and executed.
Forking Internals¶
WorldService.fork_world() creates a new world from a snapshot of an existing one.
Guard Clause¶
Forking rejects worlds with pending mutations (un-materialized spawn/despawn caches). Call step() first so _live reflects the intended snapshot:
if has_pending_spawns or has_pending_despawns:
raise ValueError("Cannot fork a world with pending mutations. ...")
What's Cloned¶
The fork receives a fresh world_id (system-generated, not caller-controlled). State copying:
| State | Copied | Notes |
|---|---|---|
tick, run_id |
Yes | Fork continues from the same tick |
_entity2sig |
Yes | Deep copy of entity-to-signature mapping |
_next_entity_id |
Yes | Entity ID counter |
_live snapshots |
Yes | Re-stamped with new world_id |
| Processors | Yes | Shared instances (stateless transforms) |
| Non-broker resources | Yes | Selective copy, skipping CommandBroker |
CommandBroker |
No | Re-injected by WorldService.create_world() |
| Spawn/despawn caches | No | Guarded -- must be empty |
| Lifecycle hooks | No | Fork-specific; source hooks are not inherited |
Persistence¶
The live snapshots are persisted to the store under the new world_id at tick source.tick - 1. This ensures store-backed reads (which query the previous tick) find the forked state on the fork's first step:
if source.tick > 0 and new_live:
persist_tick = source.tick - 1
for sig, df in new_live.items():
await new_world.updater.update(df, sig, persist_tick, ...)
Usage¶
fork = await container.world_service.fork_world(
source_world_id=world.world_id,
name="branch-A",
storage_config=storage_config,
)
fork.resources.insert(PhysicsConfig(gravity=0.0)) # override per fork
Use forking for MCTS, counterfactual reasoning, or A/B testing simulation strategies.
Source Reference¶
- World:
src/archetype/core/aio/async_world.py - World service:
src/archetype/app/world_service.py