Adding a Brain

At this point, we have a working car, but unfortunately, one without a brain. Pre-chatgpt, this next part would have been insanely difficult but now it is actually quite easy. Matter of fact, we accomplish the whole thing in just one prompt in Claude Code:

the next step i am on is that i need to create some python code. basically, i want to have code for moving the rc car forward and back and left and right. my ultimate goal is to use the camera attached to the car to take snapshots. and then use an api call to claude to choose what to do next. the working folder is a git repo. so i want the code written here before i get pull it over to the raspberry pi

But we are going to be good little citizens and actually examine what claude code did here. So let's walk through the code from our first release, starting with the directory structure:

claude_car/
├── camera.py
├── motor_control.py
├── vision_loop.py
├── .gitignore
├── test_motors.py
└── README.md

Basically, we have three main python files that control the car: camera.py, motor_control.py, and vision_loop.py. Additionally, we have a few other files that set up the environment and do miscellaneous things that aren't particularly relevant to our discussion today.

The eyes...

If we are hoping to code up a brain for our RC car, we first need to wire in a nervous system. The code in these camera.py and motor_control.py will give our car's brain the tools it needs to connect to its eyes and legs. Let's start with the camera. I am going to be spending a pretty minimal time going through each line because I did not write it, and it is likely to change in the next round of commits.

But basically, we create a class called Camera that has three different methods: capture_image, get_image_base64, and mock_capture. Additionally, our class has a few properties:

The first one, capture_image does pretty much what you would expect. It takes an image and returns the path where that image was saved. It also updates the last_image_path property, which is in tun used by the next method, get_image_base64.

This function takes the last captured image and converts it to base64, which will be sent off to Claude for processing. Nifty!

Finally, we have mock_capture, which just creates a mock image to be used in case of testing.

... the Legs...

Next, we have motor_control.py. This will basically be an amped up version of code from our last tutorial. At the top of our script, we have a little bit of set up that defines which pins on the Raspberry Pi correspond to which pins on the L298N. When the class is initialized these will be passed into the instance:

# GPIO pins (BCM numbering)
# Right side on OUT1/OUT2, Left side on OUT3/OUT4
IN1, IN2 = 18, 17   # right side: forward/backward
IN3, IN4 = 22, 27   # left side: forward/backward
ENA, ENB = 25, 24   # enable pins for speed control

PINS = [IN1, IN2, IN3, IN4, ENA, ENB]

For actually controlling things, we create a class called MotorController. It will have a good amount of methods:

Now let's examine three of these methods more closely, starting with _set_pins:

    def _set_pins(self, in1, in2, in3, in4):
        """Set individual motor direction pins (internal helper)."""
        if not self.use_gpio or not self.gpio:
            # Simulation: just log
            state = f"IN1={in1} IN2={in2} IN3={in3} IN4={in4}"
            logger.debug(f"GPIO state: {state}")
            return

        self.gpio.output(IN1, in1)
        self.gpio.output(IN2, in2)
        self.gpio.output(IN3, in3)
        self.gpio.output(IN4, in4)

Basically, you are going to pass four different values to this helper function. And it will change the value of IN pins on the L298N to either HIGH or LOW. As a reminder this determines the direction of the current through the rotor, which changes the direction that the wheels spin.

So let's take a look at the forward method. We can see that here we want the current to run from IN1 to IN2 and from IN3 to IN4. IN1 and IN2 control the direction of the right side wheels and IN3 and IN4 control the left side wheels.

    def forward(self):
        """Drive car forward."""
        self._set_pins(
            self.gpio.HIGH if self.use_gpio else True,   # IN1
            self.gpio.LOW if self.use_gpio else False,   # IN2
            self.gpio.HIGH if self.use_gpio else True,   # IN3
            self.gpio.LOW if self.use_gpio else False    # IN4
        )
        logger.debug("Moving forward")

Now, let's look at the move method:

    def move(self, direction, duration=0.5):
        """
        Execute a movement command for a given duration.

        Args:
            direction: One of 'forward', 'backward', 'left', 'right', 'stop'
            duration: How long to move (seconds)
        """
        direction = direction.lower().strip()

        if direction == 'forward':
            self.forward()
        elif direction == 'backward':
            self.backward()
        elif direction == 'left':
            self.left()
        elif direction == 'right':
            self.right()
        elif direction == 'stop':
            self.stop()
        else:
            logger.warning(f"Unknown direction: {direction}")
            self.stop()
            return

        if duration > 0:
            time.sleep(duration)
            self.stop()

This will take five different direction options and call the appropriate direction method. But where does it get that direction? Ah sweet child, we will see in the next section.

... and the Brain

Ok now we get to the fun part. Actually running the code we just created. Most of the action will be happening in the VisionControlLoop class.

First let's get the __init__ the class out of the way. Here we load the previous classes that claude built into our new class. Note that we also set up out client to interact with Claude here:

    def __init__(self, use_gpio: bool = True, headless: bool = False):
        """
        Initialize vision control loop.

        Args:
            use_gpio: If False, runs in simulation mode (no real GPIO)
            headless: If True, doesn't require display for camera preview
        """
        self.motor = MotorController(use_gpio=use_gpio)
        self.camera = Camera()
        self.client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))
        self.headless = headless

Next, we create a class called get_next_action. This one will be all about preparing a prompt to send to Claude, so we are now entering the world of prompt engineering. Once Claude gets the prompt, it will then parse that message and send it back an instruction to us. Our basic prompt to Claude is as follows:

You are controlling an RC car with a camera. Look at this image and decide what the car should do next. Move forward when possible. Do not let the car run into anything. Respond with ONLY ONE word from this list: forward, backward, left, right, or stop. No explanations, just the word.

      def get_next_action(self, image_base64: str) -> str:
        """
        Send image to Claude and get next action.

        Args:
            image_base64: Base64-encoded image string

        Returns:
            One of: 'forward', 'backward', 'left', 'right', 'stop'
        """      
  			try:
            message = self.client.messages.create(
                model="claude-haiku-4-5-20251001",
                max_tokens=100,
                messages=[
                    {
                        "role": "user",
                        "content": [
                            {
                                "type": "image",
                                "source": {
                                    "type": "base64",
                                    "media_type": "image/jpeg",
                                    "data": image_base64,
                                },
                            },
                            {
                                "type": "text",
                                "text": (
                                    "You are controlling an RC car with a camera. "
                                    "Look at this image and decide what the car should do next. Move forward when possible. Do not let the car run into anything."
                                    "Respond with ONLY ONE word from this list: "
                                    "forward, backward, left, right, or stop. "
                                    "No explanations, just the word."
                                ),
                            },
                        ],
                    }
                ],
            )

We actually use this method in the run method. This starts out with a while loop. We need to determine how long we will send prompts back and forth to Claude. By convention, Claude landed on 5 iterations.

    def run(self, iterations: int = None, duration_per_action: float = 0.5):
        """
        Run the vision control loop.

        Args:
            iterations: Number of action cycles to run. None = infinite.
            duration_per_action: How long to execute each action (seconds)
        """
        logger.info("Starting vision control loop...")
        logger.info(
            f"Configuration: iterations={iterations}, "
            f"duration={duration_per_action}s, headless={self.headless}"
        )

        iteration = 0
        try:
            while iterations is None or iteration < iterations:
                iteration += 1
                logger.info(f"\n--- Iteration {iteration} ---")

                # Capture image
                try:
                    image_path = self.camera.capture_image()
                except FileNotFoundError:
                    logger.info(
                        "Camera not available (not on Pi). Using mock image."
                    )
                    image_path = self.camera.mock_capture()

                # Encode to base64
                image_b64 = self.camera.get_image_base64(image_path)
                logger.info(f"Captured: {image_path.name} ({len(image_b64)} bytes)")

                # Get decision from Claude
                logger.info("Sending to Claude for vision analysis...")
                action = self.get_next_action(image_b64)
                logger.info(f"Claude decided: {action}")

                # Execute action
                logger.info(f"Executing: {action} for {duration_per_action}s")
                self.motor.move(action, duration=duration_per_action)

        except KeyboardInterrupt:
            logger.info("\nInterrupt received, stopping...")
        except Exception as e:
            logger.error(f"Unexpected error: {e}", exc_info=True)
        finally:
            self.cleanup()

Let's examine some of this more closely. First, see where we actually capture the image. We finally get to use the capture_image method from the camera class:

                # Capture image
                try:
                    image_path = self.camera.capture_image()
                except FileNotFoundError:
                    logger.info(
                        "Camera not available (not on Pi). Using mock image."
                    )
                    image_path = self.camera.mock_capture()

We then encode it before we send it to Claude:

                # Encode to base64
                image_b64 = self.camera.get_image_base64(image_path)
                logger.info(f"Captured: {image_path.name} ({len(image_b64)} bytes)")

Claude then reads the image and the prompt using the get_next_action method:

                # Get decision from Claude
                logger.info("Sending to Claude for vision analysis...")
                action = self.get_next_action(image_b64)
                logger.info(f"Claude decided: {action}")

And finally, we execute movement:

                # Execute action
                logger.info(f"Executing: {action} for {duration_per_action}s")
                self.motor.move(action, duration=duration_per_action)

And that's how we get a little robot wants to move forward and makes five different decisions on if it can safely do that. I will admit that it is tediously slow at this point, and I want to sniff out some improvements to make this run a bit quicker.