The CommandType.CUSTOM type lets you extend the command system with domain-specific mutations. Custom commands flow through the same RBAC, priority queue, and audit trail as built-in commands.
Submitting a Custom Command¶
Custom commands carry an arbitrary JSON payload:
from archetype.app.models import Command, CommandType
cmd = Command(
type=CommandType.CUSTOM,
payload={
"action": "trigger_event",
"event_type": "explosion",
"position": {"x": 10, "y": 20},
},
)
await container.command_service.submit(world_id, cmd, ctx)
Via the REST API:
curl -X POST localhost:8000/worlds/{world_id}/commands \
-H 'Content-Type: application/json' \
-d '{
"type": "custom",
"tick": 0,
"payload": {
"action": "trigger_event",
"event_type": "explosion",
"position": {"x": 10, "y": 20}
}
}'
Default Behavior¶
By default, CommandService.apply() is a no-op for custom commands:
# src/archetype/app/command_service.py
case CommandType.CUSTOM:
pass # No default handler
The command is still enqueued, RBAC-checked, drained at the correct tick, and logged to audit history — it just doesn't mutate world state unless you add a handler.
Registering a Handler¶
To handle custom commands, subclass CommandService and override apply():
from archetype.app.command_service import CommandService
from archetype.app.models import Command, CommandType
from archetype.core.aio.async_world import AsyncWorld
class GameCommandService(CommandService):
"""CommandService with domain-specific custom handlers."""
async def apply(self, world: AsyncWorld, cmd: Command) -> None:
if cmd.type == CommandType.CUSTOM:
await self._dispatch_custom(world, cmd)
else:
await super().apply(world, cmd)
async def _dispatch_custom(self, world: AsyncWorld, cmd: Command) -> None:
action = cmd.payload.get("action")
match action:
case "trigger_event":
await self._handle_trigger_event(world, cmd.payload)
case "apply_buff":
await self._handle_apply_buff(world, cmd.payload)
case _:
logger.warning(f"Unknown custom action: {action}")
async def _handle_trigger_event(self, world, payload):
# Your domain logic here
...
async def _handle_apply_buff(self, world, payload):
...
Wire it into your ServiceContainer by replacing the default CommandService:
container = ServiceContainer()
container.command_service = GameCommandService(container.broker)
RBAC and Quotas¶
Custom commands are governed by the same rules as built-in commands:
| Aspect | Value |
|---|---|
| Required role | player or admin |
| Token cost | 10 tokens per command |
| Per-tick limit | 500 commands (shared across all types) |
| Daily budget | 200,000 tokens (shared across all types) |
See the Token Costs and Quotas guide for details.
Command Flow¶
Custom commands follow the standard lifecycle:
Client submits Command(type=CUSTOM, payload={...})
|
v
CommandService.submit()
|
v
CommandBroker.enqueue()
- RBAC check (guardrail_allow)
- Push to priority queue keyed by (tick, priority, seq)
- Record in audit history
|
v
[Queued until target tick]
|
v
SimulationService.step()
-> CommandService.drain_and_apply()
-> broker.dequeue_due(world_id, tick)
-> apply(world, cmd) <-- your handler runs here
-> broker.ack(cmd)
Example: Domain-Specific Mutations¶
A game where agents can cast spells:
# Submit a spell command
spell_cmd = Command(
type=CommandType.CUSTOM,
payload={
"action": "cast_spell",
"caster_id": 42,
"spell": "fireball",
"target_ids": [7, 13],
"damage": 50,
},
tick=0, # Execute immediately
priority=-10, # High priority (lower = sooner)
)
await container.command_service.submit(world_id, spell_cmd, ctx)
Handle it in your custom service:
async def _handle_cast_spell(self, world, payload):
for target_id in payload["target_ids"]:
# Read current HP, apply damage, write back
await world.add_components(
target_id,
[DamageEvent(amount=payload["damage"], source=payload["spell"])],
)
Tips¶
- Use
payload["action"]as a sub-type discriminator. This keeps a singleCUSTOMtype while supporting many domain actions. - Custom commands appear in broker history. Use
container.broker._history[world_id]for audit trails. - Tick scheduling works normally. Set
tick=5to defer execution until tick 5. - Priority ordering works normally. Lower priority values execute first within a tick.