Phase 10: Score a Yahtzee roll

At the end of this phase, a Yahtzee scorer runs on your machine. You send it a command, and it reads the five dice through dice-detector and returns what the roll scores in each Yahtzee category. This is where your model stops being a number on an evaluation page and starts doing a job.

What you’re building

You write the scorer as an inline module. A module is code that viam-server runs as part of your machine, the same way it runs the ML model service module you deployed in Phase 9. An inline module is one you write in a code editor in the Viam app. When you save, Viam stores your code and deploys it to your machine, so you don’t need a terminal, a local Python install or an API key.

Your module is a generic service: a service with no fixed set of methods of its own. You run it by sending it a command, a small piece of JSON such as {"score": {}}, from the Test section of its card. Its do_command method reads the command and decides what to do.

The module doesn’t connect to the machine; it’s already part of it. Instead, you tell it which resources to use in its configuration, and viam-server hands them to it when it starts.

Create the module

  1. On the CONFIGURE tab, click + and select Code.
  2. Select Viam-hosted and click Choose.
  3. Click Proceed.
  4. Name the module yahtzee, select Python, and click Create module. The Viam app adds a generic service to your machine, with a name such as code-1, and opens its card with a code editor.
  5. On the new service’s card, click the … menu and select Rename. Enter yahtzee-scorer as the new name.

The editor holds a template with three methods to fill in: validate_config, new and do_command.

Checkpoint

The CONFIGURE tab shows a service named yahtzee-scorer with a code editor, and the code starts with from typing import ClassVar. If you don’t see the editor, click yahtzee-scorer in the list on the left.

Write the scorer

The code goes in two places in the template.

First, add the scoring code above the class. Find the line that starts with class MyGenericService and paste this code above the comment block just before it:

import asyncio
from collections import Counter

from viam.services.vision import VisionClient

SETTLED_READS = 4  # matching reads in a row
TIMEOUT_SECONDS = 10  # give up if the dice haven't settled by then
UPPER = ["Ones", "Twos", "Threes", "Fours", "Fives", "Sixes"]


def score(dice):
    counts = sorted(Counter(dice).values(), reverse=True)
    faces = set(dice)
    total = sum(dice)
    scores = {}
    for face, name in enumerate(UPPER, start=1):
        scores[name] = face * dice.count(face)
    scores["Three of a kind"] = total if counts[0] >= 3 else 0
    scores["Four of a kind"] = total if counts[0] >= 4 else 0
    scores["Full house"] = 25 if counts == [3, 2] else 0
    small = any({s, s + 1, s + 2, s + 3} <= faces for s in (1, 2, 3))
    scores["Small straight"] = 30 if small else 0
    scores["Large straight"] = 40 if faces in ({1, 2, 3, 4, 5}, {2, 3, 4, 5, 6}) else 0
    scores["Yahtzee"] = 50 if counts[0] == 5 else 0
    scores["Chance"] = total
    return {name: points for name, points in scores.items() if points}

The score function works out every category’s points for five dice, and returns only the categories worth more than zero.

Next, replace the methods. Keep the class MyGenericService line and the MODEL line below it exactly as they are; Viam uses them to identify your module. Select everything after the MODEL line, to the end of the file, and replace it with this code:

    @classmethod
    def validate_config(
        cls, config: ComponentConfig
    ) -> Tuple[Sequence[str], Sequence[str]]:
        attrs = struct_to_dict(config.attributes)
        for name in ("detector", "camera"):
            if not isinstance(attrs.get(name), str) or not attrs[name]:
                raise ValueError(f"attribute '{name}' is required")
        return [attrs["detector"]], []

    @classmethod
    def new(
        cls, config: ComponentConfig, dependencies: Mapping[ResourceName, ResourceBase]
    ) -> Self:
        self = super().new(config, dependencies)
        attrs = struct_to_dict(config.attributes)
        self.detector = dependencies[VisionClient.get_resource_name(attrs["detector"])]
        self.camera = attrs["camera"]
        return self

    async def read_dice(self):
        detections = await self.detector.get_detections_from_camera(self.camera)
        return sorted(int(d.class_name) for d in detections)

    async def read_settled_roll(self):
        loop = asyncio.get_running_loop()
        deadline = loop.time() + TIMEOUT_SECONDS
        last, matches = None, 0
        while loop.time() < deadline:
            dice = await self.read_dice()
            matches = matches + 1 if dice == last else 1
            last = dice
            if matches == SETTLED_READS:
                return dice
            await asyncio.sleep(0.25)
        return None

    async def do_command(
        self,
        command: Mapping[str, ValueTypes],
        *,
        timeout: Optional[float] = None,
        **kwargs
    ) -> Mapping[str, ValueTypes]:
        if "read" in command:
            return {"dice": await self.read_dice()}
        if "score" in command:
            dice = await self.read_settled_roll()
            if dice is None:
                return {"error": "The dice didn't settle. Keep them still and try again."}
            if len(dice) != 5:
                return {"dice": dice, "error": f"I see {len(dice)} dice. Reroll so all five show."}
            return {"dice": dice, "scores": score(dice)}
        raise ValueError('Send {"score": {}} or {"read": {}}.')

    async def get_status(
        self, *, timeout: Optional[float] = None, **kwargs
    ) -> Mapping[str, ValueTypes]:
        return {}

Here’s what each part does:

  • validate_config checks the module’s configuration for two names: detector, the vision service to read from, and camera, the camera it reads. It returns detector as a dependency, so viam-server starts the module only after dice-detector is running.
  • new runs when the module starts. It looks up dice-detector in the dependencies viam-server passes in, and keeps it for later.
  • read_dice asks the vision service to take an image from the camera and run the model on it. Each detection’s class_name is the label you drew in Phase 2, 1 through 6, and the vision service has already dropped boxes below your confidence threshold.
  • read_settled_roll handles dice that are still rolling, which read differently in each image. It reads the dice again a quarter of a second after each read finishes, and returns a roll only when four reads in a row agree. Each read also waits for the model to run, so four reads take at least one second, and longer on a slower model. If the dice don’t settle within 10 seconds, it gives up.
  • do_command runs a command. {"read": {}} returns what the model sees right now. {"score": {}} waits for a settled roll and, if it sees exactly five dice, returns their scores. A sixth box usually means a misread or an extra die in view.

Configure and save

  1. In the yahtzee-scorer card, find its attributes and replace them with:

    {
      "detector": "dice-detector",
      "camera": "camera"
    }
    
  2. Click Save.

Saving deploys your code to the machine. Python inline modules run from source, so there’s no build step; the module starts as soon as the machine receives it.

Checkpoint

After a few seconds, yahtzee-scorer shows no error on the CONFIGURE tab. If it shows an error, open the LOGS tab and read it. An error about an attribute being required means the attributes don’t match the JSON above. A Python error with a line number usually means part of the code was pasted in the wrong place; check that the MODEL line is still there and that the pasted methods are indented under the class.

Score a roll

  1. Roll five dice onto the background, and move any other dice out of the camera’s view.
  2. In the yahtzee-scorer card, open the Test section and expand DoCommand.
  3. Enter {"read": {}} and click Execute.
  4. Check that the five values match the dice.
  5. Enter {"score": {}} and click Execute.

Checkpoint

For a roll of 3 3 3 5 5, {"score": {}} returns:

{
  "dice": [3, 3, 3, 5, 5],
  "scores": {
    "Threes": 9,
    "Fives": 10,
    "Three of a kind": 19,
    "Full house": 25,
    "Chance": 19
  }
}

If it returns an error saying it sees fewer or more than five dice, run {"read": {}} to see what it reads, or check the dice-detector test panel. If it says the dice didn’t settle, keep them still and run it again.

Count how often it scores correctly

Your evaluation in Phase 8 measured boxes. This test measures what you care about: whole rolls.

  1. Roll 10 times, running {"score": {}} after each roll.
  2. For each roll, check the returned dice against the dice, and count the rolls where all five values are right.

Expect this number to be lower than the recall from Phase 8. Recall counts each die on its own, but a roll is right only when all five dice are right at once. If the model reads each die correctly 95% of the time, the chance that it reads all five correctly is 0.95 multiplied by itself five times, about 77%. A stray extra box also makes a roll wrong, so the real number can be lower still.

Milestone

A Yahtzee scorer built on your own trained model runs on your machine as a module. Write down how many of the 10 rolls it read correctly. A model with 95% recall gets about 7 or 8 right, and a different 10 rolls can easily come out one or two higher or lower. If it got 5 or fewer, look at which faces it got wrong, and compare them with the per-class breakdown from Phase 8.