Skip to content

Your First Actor

In this guide you'll build a traffic light — a state machine that cycles through green, yellow, and red. Along the way you'll learn the three core ideas in Casty: messages, behaviors, and state transitions.

Messages

Every actor communicates through messages. In Casty, messages are frozen dataclasses — immutable values that are safe to send between actors.

Our traffic light handles two messages:

@dataclass(frozen=True)
class Tick:
    pass


@dataclass(frozen=True)
class GetColor:
    reply_to: ActorRef[str]


type TrafficLightMsg = Tick | GetColor

Tick advances the light to the next color. GetColor is a request-reply message: the sender includes its own ActorRef so the light can respond.

Behaviors as State

Instead of a single handler with an if color == "green" check, each color is its own behavior function. The traffic light is whichever behavior is currently active:

def green() -> Behavior[TrafficLightMsg]:
    async def receive(
        ctx: ActorContext[TrafficLightMsg], msg: TrafficLightMsg
    ) -> Behavior[TrafficLightMsg]:
        match msg:
            case Tick():
                print("🟢 → 🟡")
                return yellow()
            case GetColor(reply_to):
                reply_to.tell("green")
                return Behaviors.same()

    return Behaviors.receive(receive)


def yellow() -> Behavior[TrafficLightMsg]:
    async def receive(
        ctx: ActorContext[TrafficLightMsg], msg: TrafficLightMsg
    ) -> Behavior[TrafficLightMsg]:
        match msg:
            case Tick():
                print("🟡 → 🔴")
                return red()
            case GetColor(reply_to):
                reply_to.tell("yellow")
                return Behaviors.same()

    return Behaviors.receive(receive)


def red() -> Behavior[TrafficLightMsg]:
    async def receive(
        ctx: ActorContext[TrafficLightMsg], msg: TrafficLightMsg
    ) -> Behavior[TrafficLightMsg]:
        match msg:
            case Tick():
                print("🔴 → 🟢")
                return green()
            case GetColor(reply_to):
                reply_to.tell("red")
                return Behaviors.same()

    return Behaviors.receive(receive)

Notice: there are no mutable variables. No self.color = "yellow". When the light receives a Tick, it returns a different behavior — and that is the state transition. This is called behavior recursion.

Running It

The main() function spawns the actor and sends messages:

async def main() -> None:
    async with ActorSystem() as system:
        light = system.spawn(green(), "traffic-light")

        light.tell(Tick())  # green → yellow
        light.tell(Tick())  # yellow → red
        light.tell(Tick())  # red → green

        await asyncio.sleep(0.1)

        color = await system.ask(
            light, lambda r: GetColor(reply_to=r), timeout=5.0
        )
        print(f"Current color: {color}")  # green


asyncio.run(main())

Output:

🟢 → 🟡
🟡 → 🔴
🔴 → 🟢
Current color: green

Run the Full Example

git clone https://github.com/gabfssilva/casty.git
cd casty
uv run python examples/guides/01_your_first_actor.py

What you learned:

  • Messages are frozen dataclasses — immutable and type-safe.
  • Behaviors are values, not classes. Each state is a function that returns a Behavior.
  • State transitions happen by returning a different behavior — no mutation needed.
  • Request-reply works by including an ActorRef in the message.