Skip to content

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:

  1. Understand how commands work in MAID
  2. Create command handlers with proper structure
  3. Use decorators for registration and argument parsing
  4. Build three practical commands step by step
  5. 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:

  1. Parses the input to identify the command name and arguments
  2. Looks up the command in the registry
  3. Checks permissions (access level and locks)
  4. Runs any pre-command hooks
  5. Executes the command handler
  6. 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 successfully
  • False - 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:

  1. The decorator parses ctx.args using your specs
  2. Errors are automatically sent to the player
  3. A ParsedArguments object is passed as the second argument
  4. 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 @command decorator 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 @arguments for 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.REST to capture remaining input
  • Using ArgumentType.PLAYER to 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:

uv run maid server start --debug

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:

uv run pytest tests/test_commands.py -v

What's Next?

Congratulations! You now know how to:

  • Create command handlers with proper structure
  • Use the @command decorator for registration
  • Parse arguments with @arguments and @pattern
  • Work with CommandContext and ParsedArguments
  • 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"