Tutorial Part 3: Your First Commands¶
Estimated time: 30 minutes
Welcome back! In Part 2, you learned about MAID's architecture - the Entity Component System, tick loop, events, and content packs. Now it is time to put that knowledge into practice by creating your own commands.
What You Will Learn¶
By the end of this tutorial, you will:
- Understand how commands work in MAID
- Create command handlers with proper structure
- Use decorators for registration and argument parsing
- Build three practical commands step by step
- Register commands in a content pack
What is a Command?¶
In a MUD, commands are how players interact with the game world. When a player types look or attack goblin, the engine:
- Parses the input to identify the command name and arguments
- Looks up the command in the registry
- Checks permissions (access level and locks)
- Runs any pre-command hooks
- Executes the command handler
- Runs any post-command hooks
Commands in MAID are asynchronous Python functions decorated with metadata that tells the engine how to handle them.
How MAID's Command System Works¶
MAID uses a layered command registry where multiple content packs can register the same command name. When executed, the highest-priority registration wins. This allows:
- The standard library to provide default implementations
- Content packs to override or extend behavior
- Games to customize commands without modifying core code
Player types: "look at sword"
|
v
+-------------------+
| Command Registry | <- Finds "look" command
+-------------------+
|
v
+-------------------+
| Priority Check | <- Chooses highest priority "look"
+-------------------+
|
v
+-------------------+
| Access/Lock Check | <- Verifies permissions
+-------------------+
|
v
+-------------------+
| Pre-Hooks | <- Rate limiting, logging, etc.
+-------------------+
|
v
+-------------------+
| Command Handler | <- Your code executes here
+-------------------+
|
v
+-------------------+
| Post-Hooks | <- Metrics, cleanup, etc.
+-------------------+
Command Handler Structure¶
A command handler is an async function that receives a CommandContext and returns a boolean indicating success.
Basic Handler Signature¶
from maid_engine.commands import CommandContext
async def cmd_example(ctx: CommandContext) -> bool:
"""A simple command handler."""
# Your command logic here
await ctx.session.send("Command executed!\n")
return True
The CommandContext Object¶
The CommandContext provides everything your command needs:
| Attribute | Type | Description |
|---|---|---|
session |
Session | The player's network session for sending output |
player_id |
UUID | The UUID of the player entity |
command |
str | The command name that was typed |
args |
list[str] | Arguments split by whitespace |
raw_input |
str | The complete raw input string |
world |
World | The game world for entity access |
document_store |
DocumentStore | Optional persistence layer |
metadata |
dict | Additional context data |
Return Values¶
Command handlers return bool:
True- Command executed successfullyFalse- Command failed (invalid arguments, target not found, etc.)
The return value is used by hooks and can affect things like cooldowns (a failed command might not trigger a cooldown).
Example: Accessing Context Data¶
async def cmd_whereami(ctx: CommandContext) -> bool:
"""Show the player their current location."""
# Get player entity from world
player = ctx.world.get_entity(ctx.player_id)
if not player:
await ctx.session.send("You don't exist!\n")
return False
# Get current room
room_id = ctx.world.get_entity_room(ctx.player_id)
if not room_id:
await ctx.session.send("You are nowhere.\n")
return False
room = ctx.world.get_room(room_id)
await ctx.session.send(f"You are in: {room.name}\n")
return True
Command Registration¶
Commands must be registered with the engine before players can use them. MAID provides the @command decorator for this.
The @command Decorator¶
from maid_engine.commands import command, CommandContext
@command(
name="greet",
aliases=["hello", "hi"],
category="social",
help_text="Greet everyone in the room",
)
async def cmd_greet(ctx: CommandContext) -> bool:
"""Send a friendly greeting to the room."""
await ctx.session.send("You wave hello to everyone!\n")
return True
Decorator Parameters¶
| Parameter | Type | Description |
|---|---|---|
name |
str | Primary command name (required) |
aliases |
list[str] | Alternative names for the command |
category |
str | Category for help organization (e.g., "combat", "social") |
help_text |
str | Short description shown in help listings |
access_level |
int | Minimum access level (0=player, 2=builder, 3=admin) |
locks |
str | Lock expression for permission checks |
metadata |
dict | Custom data for hooks (cooldowns, etc.) |
Access Levels¶
MAID defines standard access levels:
| Level | Name | Value | Use Case |
|---|---|---|---|
| PLAYER | Player | 0 | Normal player commands |
| HELPER | Helper | 1 | Community helpers |
| BUILDER | Builder | 2 | World building commands |
| ADMIN | Admin | 3 | Administrative commands |
| IMPLEMENTOR | Implementor | 4 | Full access |
from maid_engine.commands import command, AccessLevel
@command(
name="@teleport",
aliases=["@tp"],
category="admin",
help_text="Teleport to a location",
access_level=AccessLevel.ADMIN,
)
async def cmd_teleport(ctx: CommandContext) -> bool:
# Only admins can use this
...
Priority for Command Layering¶
When registering commands in a content pack, you can set priority:
def register_commands(registry, pack_name="my-game"):
# Higher priority overrides lower priority
registry.set_pack_priority("my-game", 100) # High priority
registry.set_pack_priority("stdlib", 50) # Medium priority
# This will override stdlib's "look" command
registry.register("look", my_custom_look, pack_name)
Argument Parsing with Decorators¶
For commands with arguments, MAID provides declarative parsing that validates and converts input automatically.
The @arguments Decorator¶
from maid_engine.commands import (
command,
arguments,
ArgumentSpec,
ArgumentType,
ParsedArguments,
CommandContext,
)
@command(name="give", category="items", help_text="Give gold to someone")
@arguments(
ArgumentSpec("target", ArgumentType.PLAYER, description="Who to give gold to"),
ArgumentSpec(
"amount",
ArgumentType.INTEGER,
required=True,
min_value=1,
max_value=1000,
description="Amount of gold",
),
)
async def cmd_give_gold(ctx: CommandContext, args: ParsedArguments) -> bool:
"""Give gold to another player."""
target = args["target"] # Player entity
amount = args["amount"] # int (validated to be 1-1000)
if not target:
await ctx.session.send("That player is not online.\n")
return False
await ctx.session.send(f"You give {amount} gold to {target.name}.\n")
return True
When you use @arguments:
- The decorator parses
ctx.argsusing your specs - Errors are automatically sent to the player
- A
ParsedArgumentsobject is passed as the second argument - If parsing fails, your handler is not called
Argument Types¶
| Type | Description | Parsed Value |
|---|---|---|
STRING |
Raw string | str |
INTEGER |
Integer number | int |
FLOAT |
Decimal number | float |
BOOLEAN |
True/false value | bool |
ENTITY |
Single entity reference | EntityReference |
ENTITY_LIST |
Multiple entities | EntityReference |
DIRECTION |
Cardinal direction | str (normalized) |
PLAYER |
Online player | Entity |
REST |
Remaining input | str |
ArgumentSpec Options¶
ArgumentSpec(
name="target", # Argument name (required)
type=ArgumentType.ENTITY, # Argument type (default: STRING)
required=True, # Whether argument is required (default: True)
default=None, # Default value if not provided
description="", # Help text description
# STRING-specific
choices=["a", "b", "c"], # Valid choices
pattern=r"^\w+$", # Regex pattern to match
# INTEGER/FLOAT-specific
min_value=1, # Minimum value
max_value=100, # Maximum value
# ENTITY-specific
search_scope=SearchScope.ROOM_AND_INVENTORY, # Where to search
)
Pattern-Based Parsing with @pattern¶
For complex formats like "give X to Y" or "put X in Y", use @pattern:
from maid_engine.commands import (
command,
pattern,
ArgumentSpec,
ArgumentType,
SearchScope,
ParsedArguments,
CommandContext,
)
@command(name="give", category="items", help_text="Give an item to someone")
@pattern(
"<item> to <target>",
item=ArgumentSpec(
"item",
ArgumentType.ENTITY,
search_scope=SearchScope.INVENTORY,
description="Item from your inventory",
),
target=ArgumentSpec(
"target",
ArgumentType.ENTITY,
search_scope=SearchScope.ROOM,
description="Person to give item to",
),
)
async def cmd_give(ctx: CommandContext, args: ParsedArguments) -> bool:
"""Give an item to someone in the room."""
item = args["item"]
target = args["target"]
if not item.found:
await ctx.session.send("You don't have that item.\n")
return False
if not target.found:
await ctx.session.send("You don't see them here.\n")
return False
await ctx.session.send(f"You give {item.entity.name} to {target.entity.name}.\n")
return True
Pattern syntax:
<name>- Placeholder that maps to an ArgumentSpec- Literal text must match exactly
- Whitespace is flexible (multiple spaces treated as one)
Working with EntityReference¶
When using ArgumentType.ENTITY, you get an EntityReference:
target = args["target"] # EntityReference
# Check if anything was found
if not target.found:
await ctx.session.send("Not found.\n")
return False
# Get the single entity (first match)
entity = target.entity
# Iterate all matches (for ENTITY_LIST or "all.X")
for entity in target:
print(entity.id)
# Get count of matches
count = len(target)
# Access original search keyword
print(f"You searched for: {target.keyword}")
Entity search supports special syntax:
| Syntax | Description | Example |
|---|---|---|
me |
Player's own entity | examine me |
here |
Current room | look here |
<keyword> |
First matching entity | get sword |
2.<keyword> |
Nth matching entity | get 2.sword |
all.<keyword> |
All matching entities | drop all.sword |
Example Commands to Build¶
Let's build three practical commands to solidify your understanding.
Exercise 1: Time Command¶
A simple command that shows the current in-game time.
from datetime import datetime
from maid_engine.commands import command, CommandContext
@command(
name="time",
aliases=["clock"],
category="information",
help_text="Shows the current game time",
)
async def cmd_time(ctx: CommandContext) -> bool:
"""Display the current in-game time."""
# In a real game, you would get time from a TimeSystem
# For now, we'll use real time as a placeholder
now = datetime.now()
# Format as game-style time
hour = now.hour
period = "morning" if hour < 12 else "afternoon" if hour < 17 else "evening" if hour < 21 else "night"
await ctx.session.send(f"\nThe time is {now.strftime('%H:%M')}.\n")
await ctx.session.send(f"It is currently {period}.\n")
return True
What you learned:
- Basic command structure with no arguments
- Using
@commanddecorator with aliases - Sending formatted output to the player
Exercise 2: Roll Command¶
A dice rolling command that demonstrates argument parsing.
from maid_engine.commands import (
command,
arguments,
ArgumentSpec,
ArgumentType,
ParsedArguments,
CommandContext,
)
from maid_stdlib.utils import parse_dice_notation
@command(
name="roll",
aliases=["dice"],
category="utility",
help_text="Roll dice (e.g., roll 2d6+3)",
)
@arguments(
ArgumentSpec(
"dice",
ArgumentType.STRING,
required=False,
default="1d20",
description="Dice notation like 2d6 or 3d8+5",
),
)
async def cmd_roll(ctx: CommandContext, args: ParsedArguments) -> bool:
"""Roll dice using standard notation."""
notation = args["dice"]
try:
result = parse_dice_notation(notation)
except ValueError as e:
await ctx.session.send(f"Invalid dice notation: {e}\n")
await ctx.session.send("Use format like: 2d6, 1d20, 3d8+5\n")
return False
# Announce the roll
await ctx.session.send(f"\n{ctx.command}: {result}\n")
# In a multiplayer game, you might broadcast this to the room
# world.broadcast_to_room(room_id, f"{player_name} rolls {result}")
return True
What you learned:
- Using
@argumentsfor optional arguments with defaults - Error handling with user feedback
- Integrating with utility functions from maid-stdlib
Exercise 3: Whisper Command¶
A private message command demonstrating pattern-based parsing.
from maid_engine.commands import (
command,
pattern,
ArgumentSpec,
ArgumentType,
ParsedArguments,
CommandContext,
)
from maid_stdlib.components import DescriptionComponent
@command(
name="whisper",
aliases=["tell", "msg"],
category="communication",
help_text="Send a private message to another player",
)
@pattern(
"<target> <message>",
target=ArgumentSpec(
"target",
ArgumentType.PLAYER,
description="Player to message",
),
message=ArgumentSpec(
"message",
ArgumentType.REST,
description="Your message",
),
)
async def cmd_whisper(ctx: CommandContext, args: ParsedArguments) -> bool:
"""Send a private message to another player."""
target = args["target"]
message = args["message"]
if not target:
await ctx.session.send("That player is not online.\n")
return False
if not message.strip():
await ctx.session.send("What do you want to say?\n")
return False
# Get sender's name
sender = ctx.world.get_entity(ctx.player_id)
sender_desc = sender.try_get(DescriptionComponent) if sender else None
sender_name = sender_desc.name if sender_desc else "Someone"
# Get target's name
target_desc = target.try_get(DescriptionComponent)
target_name = target_desc.name if target_desc else "them"
# Send to target
# In a real implementation, you would get the target's session
# and send directly to them
# target_session.send(f"\n{sender_name} whispers to you: {message}\n")
# Confirm to sender
await ctx.session.send(f"\nYou whisper to {target_name}: {message}\n")
return True
What you learned:
- Pattern-based parsing with
@pattern - Using
ArgumentType.RESTto capture remaining input - Using
ArgumentType.PLAYERto find online players - Working with entity components
Registering Commands in a Content Pack¶
Now let's put it all together by registering commands in a content pack.
Creating a Commands Module¶
Create a file for your commands:
# my_game/commands/utility.py
"""Utility commands for my game."""
from datetime import datetime
from maid_engine.commands import (
command,
arguments,
pattern,
ArgumentSpec,
ArgumentType,
ParsedArguments,
CommandContext,
)
from maid_stdlib.utils import parse_dice_notation
@command(
name="time",
aliases=["clock"],
category="information",
help_text="Shows the current game time",
)
async def cmd_time(ctx: CommandContext) -> bool:
"""Display the current in-game time."""
now = datetime.now()
hour = now.hour
period = "morning" if hour < 12 else "afternoon" if hour < 17 else "evening" if hour < 21 else "night"
await ctx.session.send(f"\nThe time is {now.strftime('%H:%M')}.\n")
await ctx.session.send(f"It is currently {period}.\n")
return True
@command(
name="roll",
aliases=["dice"],
category="utility",
help_text="Roll dice (e.g., roll 2d6+3)",
)
@arguments(
ArgumentSpec("dice", ArgumentType.STRING, required=False, default="1d20"),
)
async def cmd_roll(ctx: CommandContext, args: ParsedArguments) -> bool:
"""Roll dice using standard notation."""
notation = args["dice"]
try:
result = parse_dice_notation(notation)
except ValueError as e:
await ctx.session.send(f"Invalid dice notation: {e}\n")
return False
await ctx.session.send(f"\n{result}\n")
return True
@command(
name="whisper",
aliases=["tell", "msg"],
category="communication",
help_text="Send a private message",
)
@pattern(
"<target> <message>",
target=ArgumentSpec("target", ArgumentType.PLAYER),
message=ArgumentSpec("message", ArgumentType.REST),
)
async def cmd_whisper(ctx: CommandContext, args: ParsedArguments) -> bool:
"""Send a private message to another player."""
target = args["target"]
message = args["message"]
if not target:
await ctx.session.send("That player is not online.\n")
return False
await ctx.session.send(f"\nYou whisper: {message}\n")
return True
Using register_commands() in Your ContentPack¶
In your content pack class, implement register_commands():
# my_game/pack.py
"""My game content pack."""
from maid_engine.plugins.protocol import BaseContentPack
from maid_engine.plugins.manifest import ContentPackManifest
from maid_engine.commands import LayeredCommandRegistry, get_command_info, is_command
class MyGameContentPack(BaseContentPack):
@property
def manifest(self) -> ContentPackManifest:
return ContentPackManifest(
name="my-game",
version="1.0.0",
display_name="My Game",
description="Custom content for my MUD",
)
def get_dependencies(self) -> list[str]:
return ["maid-stdlib"]
def register_commands(self, registry: LayeredCommandRegistry) -> None:
"""Register all commands from this content pack."""
from my_game.commands.utility import cmd_time, cmd_roll, cmd_whisper
pack_name = self.manifest.name
# Register each decorated command
for handler in [cmd_time, cmd_roll, cmd_whisper]:
if is_command(handler):
info = get_command_info(handler)
registry.register(
name=info["name"],
handler=handler,
pack_name=pack_name,
aliases=info["aliases"],
category=info["category"] or "general",
description=info["help_text"],
access_level=info["access_level"],
locks=info["locks"],
metadata=info["metadata"],
)
Auto-Discovery Pattern¶
For larger content packs, you can auto-discover commands:
def register_commands(self, registry: LayeredCommandRegistry) -> None:
"""Auto-discover and register all commands."""
import my_game.commands.utility as utility_module
import my_game.commands.combat as combat_module
pack_name = self.manifest.name
for module in [utility_module, combat_module]:
for name in dir(module):
if name.startswith("cmd_"):
handler = getattr(module, name)
if callable(handler) and is_command(handler):
info = get_command_info(handler)
registry.register(
name=info["name"],
handler=handler,
pack_name=pack_name,
aliases=info["aliases"],
category=info["category"] or "general",
description=info["help_text"],
access_level=info["access_level"],
locks=info["locks"],
metadata=info["metadata"],
)
Testing Your Commands¶
Running the Server¶
Start the server with your content pack loaded:
Testing In-Game¶
Connect via telnet and try your commands:
$ telnet localhost 4000
Connected to localhost.
Welcome to MAID!
Enter your character name: TestPlayer
Password: ********
Town Square
You are standing in the center of a bustling town square...
> time
The time is 14:32.
It is currently afternoon.
> roll 2d6+3
2d6+3 = [4, 5] +3 = 12
> roll
1d20 = [17] = 17
> whisper Bob Hello there!
You whisper: Hello there!
> help roll
roll (dice)
Usage: roll [dice]
Roll dice (e.g., roll 2d6+3)
Writing Unit Tests¶
Create tests for your commands:
# tests/test_commands.py
import pytest
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
from my_game.commands.utility import cmd_time, cmd_roll
@pytest.fixture
def mock_context():
"""Create a mock command context."""
ctx = MagicMock()
ctx.session = AsyncMock()
ctx.player_id = uuid4()
ctx.world = MagicMock()
ctx.command = "test"
ctx.args = []
ctx.raw_input = "test"
return ctx
@pytest.mark.asyncio
async def test_time_command(mock_context):
"""Test that time command returns successfully."""
result = await cmd_time(mock_context)
assert result is True
assert mock_context.session.send.called
@pytest.mark.asyncio
async def test_roll_command_default(mock_context):
"""Test roll command with default dice."""
from maid_engine.commands import ParsedArguments
args = ParsedArguments(values={"dice": "1d20"})
# Note: The actual @arguments decorator handles parsing
# For unit tests, we call the underlying function directly
result = await cmd_roll.__wrapped__(mock_context, args)
assert result is True
@pytest.mark.asyncio
async def test_roll_command_custom_dice(mock_context):
"""Test roll command with custom dice notation."""
from maid_engine.commands import ParsedArguments
args = ParsedArguments(values={"dice": "3d6+5"})
result = await cmd_roll.__wrapped__(mock_context, args)
assert result is True
Run tests with:
What's Next?¶
Congratulations! You now know how to:
- Create command handlers with proper structure
- Use the
@commanddecorator for registration - Parse arguments with
@argumentsand@pattern - Work with
CommandContextandParsedArguments - Register commands in a content pack
- Test your commands
In the next tutorial, you will learn how to create rooms, items, and other game objects.
Coming up in Part 4: Creating Rooms & Items
- Room structure and exits
- Creating items with components
- Container mechanics
- Room descriptions and ambiance
< Previous: Part 2 - Architecture Back to Tutorial Index Next: Part 4 - Rooms & Items >
Quick Reference¶
Command Handler Template¶
from maid_engine.commands import command, CommandContext
@command(
name="mycommand",
aliases=["mc"],
category="general",
help_text="Description for help",
)
async def cmd_mycommand(ctx: CommandContext) -> bool:
"""Docstring for the command."""
# Your logic here
await ctx.session.send("Output\n")
return True
With Arguments Template¶
from maid_engine.commands import (
command,
arguments,
ArgumentSpec,
ArgumentType,
ParsedArguments,
CommandContext,
)
@command(name="mycommand", help_text="Description")
@arguments(
ArgumentSpec("target", ArgumentType.STRING),
ArgumentSpec("count", ArgumentType.INTEGER, required=False, default=1),
)
async def cmd_mycommand(ctx: CommandContext, args: ParsedArguments) -> bool:
target = args["target"]
count = args["count"]
# Your logic here
return True
With Pattern Template¶
from maid_engine.commands import (
command,
pattern,
ArgumentSpec,
ArgumentType,
ParsedArguments,
CommandContext,
)
@command(name="mycommand", help_text="Description")
@pattern(
"<thing> to <target>",
thing=ArgumentSpec("thing", ArgumentType.ENTITY),
target=ArgumentSpec("target", ArgumentType.PLAYER),
)
async def cmd_mycommand(ctx: CommandContext, args: ParsedArguments) -> bool:
thing = args["thing"]
target = args["target"]
# Your logic here
return True
Common Imports¶
from maid_engine.commands import (
# Decorators
command,
arguments,
pattern,
# Argument parsing
ArgumentSpec,
ArgumentType,
ParsedArguments,
SearchScope,
EntityReference,
# Context
CommandContext,
# Access levels
AccessLevel,
# Introspection
is_command,
get_command_info,
)
ArgumentType Quick Reference¶
| Type | Input | Output |
|---|---|---|
STRING |
"hello" |
"hello" |
INTEGER |
"42" |
42 |
FLOAT |
"3.14" |
3.14 |
BOOLEAN |
"yes", "true", "1" |
True |
DIRECTION |
"n", "north" |
"north" |
ENTITY |
"sword" |
EntityReference |
PLAYER |
"Bob" |
Player entity |
REST |
"all the words" |
"all the words" |