Hooks are typed lifecycle callbacks attached to a single world. They are intended for observability, integration glue, and lightweight side effects that need to run at known world boundaries.

The hook catalogue lives in archetype.core.hooks, a neutral core module used by both AsyncWorld and SyncWorld.

Type Model

Hook events are frozen dataclasses. Every concrete event inherits from the nominal base class HookEvent, which carries the world identity:

from archetype.core.hooks import HookEvent, PostTick

assert issubclass(PostTick, HookEvent)

The handler types are explicit about runtime mode:

Type Runtime Callable shape
AsyncHookHandler[E] AsyncWorld async def handler(event: E) -> None
SyncHookHandler[E] SyncWorld def handler(event: E) -> None

E is bound to HookEvent, so world.add_hook(PostTick, handler) ties the event class and handler argument to the same event type.

Core uses dataclasses rather than Pydantic models here because hooks are in the world hot path. Events are small immutable payloads, not validation boundaries.

Registering Hooks

from archetype.core.hooks import HookHandle, PostTick

async def log_tick(event: PostTick) -> None:
    print(f"world={event.world_id} tick={event.tick}")

handle: HookHandle = world.add_hook(PostTick, log_tick)

# Later:
world.remove_hook(handle)

add_hook() returns an opaque HookHandle. Store the handle if the hook should be removed later. Handles are registry-scoped, so a handle minted by one world cannot unregister a same-shaped hook in another world.

For a complete runnable example, see examples/07_hooks.py.

Event Catalogue

Event Payload fields Fires when
PreTick world_id, tick At the start of World.step(), before any archetype runs
PostTick world_id, tick, results After all archetypes process, _live refreshes, and tick increments
OnSpawn world_id, entity_id, components After create_entity() or 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 wider archetype
OnComponentRemoved world_id, entity_id, component_types After remove_components() moves the entity to a narrower archetype

Payloads carry world_id, not the world object. A handler that needs the world should close over it at registration time.

Tick Semantics

PreTick.tick is the tick about to run.

PostTick.tick is the newly incremented tick after the step completes. The tick that just completed is event.tick - 1.

step(tick=N)
  -> PreTick(tick=N)
  -> query, materialize mutations, execute processors, persist, refresh _live
  -> tick = N + 1
  -> PostTick(tick=N+1)

Spawn/despawn/component hooks fire when the world mutation is queued in memory, not when the row is later materialized and persisted during the tick.

Async Fire Modes

AsyncWorld.add_hook() supports two fire modes:

Mode Behavior
"blocking" Await the handler inline. This is the default. The tick waits for the hook.
"spawn" Run the handler detached with asyncio.create_task(). The tick does not wait.

Use "spawn" for telemetry or integration sinks that should not block the tick path:

world.add_hook(PostTick, publish_metrics, mode="spawn")

Detached hook failures are logged. They are not raised into the tick caller.

Sync Hooks

SyncWorld.add_hook() uses the same event dataclasses and HookHandle type, but handlers are plain callables and there is no "spawn" mode:

from archetype.core.hooks import PreTick

def trace_tick(event: PreTick) -> None:
    print(event.tick)

handle = world.add_hook(PreTick, trace_tick)
world.remove_hook(handle)

Failure Policy

Hook exceptions are logged at warning level and do not abort the world step. Hooks should not be used for transactional invariants that must stop mutation or persistence on failure. Use processors, services, or explicit command handling for behavior that must participate in simulation correctness.

Service-Layer Usage

WorldService attaches a PostTick hook when a WorldRegistry is configured. That hook updates the persisted registry tick after each completed step.

Application code should register hooks through the public world API. It should not reach into world._hooks or private fire methods.

Forking

Forked worlds do not inherit source-world hooks. A hook closes over process-local state and belongs to the specific world where it was registered. Register new hooks on the fork when needed.