Yacht in Crystal: Complete Solution & Deep Dive Guide
The Complete Guide to Solving the Yacht Game in Crystal: From Zero to Hero
This guide provides a comprehensive solution for the Yacht game logic challenge using idiomatic Crystal. We will break down the problem, design a clean implementation using Crystal's powerful collection methods, and explore the core concepts required to master this classic programming exercise from the kodikra.com curriculum.
Have you ever found yourself staring at a set of game rules, like those for a dice game, and wondered how to translate that intricate human logic into clean, efficient code? It's a common challenge. The gap between understanding the rules of a game like Yacht and implementing a robust scoring algorithm can feel vast, especially when dealing with multiple conditions, combinations, and edge cases. You might feel stuck on how to count dice, check for patterns like "straights" or "full houses," and structure it all in a way that's both readable and maintainable.
This article is your definitive roadmap. We will demystify the process entirely. We'll walk you through building a complete, elegant solution in Crystal, a language perfectly suited for this task due to its expressive syntax and powerful standard library. By the end, you won't just have a working solution; you'll understand the "why" behind each line of code and gain a deeper appreciation for Crystal's capabilities in handling collection-based logic.
What is the Yacht Game Logic?
The Yacht game is a classic dice game of chance and strategy. The core of the programming challenge, as presented in the exclusive kodikra.com learning path, is to build a score calculator. The program receives two inputs: a collection of five dice rolls (each an integer from 1 to 6) and a scoring category (a string or symbol).
The objective is to calculate the score based on the specific rule for the given category. This task requires careful analysis of the dice combination to identify patterns. For instance, some categories reward summing up dice of a specific number, while others look for complex patterns like a full house (three of one kind, two of another) or a straight (a sequence of consecutive numbers).
Understanding these categories is the first step to building the logic. Here is a complete breakdown of the scoring rules:
| Category | Score Calculation | Description | Example Dice | Example Score |
|---|---|---|---|---|
Ones |
Sum of dice that are 1 | Add up all the ones. | [1, 1, 2, 3, 4] |
2 |
Twos |
Sum of dice that are 2 | Add up all the twos. | [2, 2, 2, 1, 5] |
6 |
Threes |
Sum of dice that are 3 | Add up all the threes. | [3, 1, 2, 3, 4] |
6 |
Fours |
Sum of dice that are 4 | Add up all the fours. | [4, 4, 4, 4, 1] |
16 |
Fives |
Sum of dice that are 5 | Add up all the fives. | [5, 1, 5, 2, 5] |
15 |
Sixes |
Sum of dice that are 6 | Add up all the sixes. | [6, 6, 1, 2, 3] |
12 |
Full House |
Sum of all dice | Three of one number and two of another. | [3, 3, 3, 5, 5] |
19 |
Four of a Kind |
Sum of the four matching dice | At least four dice are the same number. | [4, 4, 4, 4, 6] |
16 |
Little Straight |
30 points | Dice are 1, 2, 3, 4, 5. | [1, 2, 3, 4, 5] |
30 |
Big Straight |
30 points | Dice are 2, 3, 4, 5, 6. | [2, 3, 4, 5, 6] |
30 |
Choice |
Sum of all dice | Any combination of dice. | [1, 3, 4, 5, 6] |
19 |
Yacht |
50 points | All five dice are the same number. | [4, 4, 4, 4, 4] |
50 |
Why Crystal is a Perfect Fit for This Challenge
While you could solve this problem in many languages, Crystal offers a unique combination of features that make it particularly well-suited for logic-heavy, data-manipulation tasks like the Yacht game.
- Expressive, Ruby-Inspired Syntax: Crystal's syntax is famously clean and readable. This allows you to write code that looks very close to plain English, making the complex game logic easier to implement and, more importantly, easier to read and maintain later.
-
Powerful Enumerable Module: The heart of this solution lies in analyzing a collection of dice. Crystal's
Enumerablemodule, which is included in types likeArray, provides a rich set of methods liketally,group_by,select, andsumthat drastically simplify tasks like counting dice frequencies and summing values. - Compile-Time Type Checking: Unlike Ruby, Crystal is a compiled language with static type checking. This means the compiler catches potential errors before you even run the program. For this problem, it ensures that you're always working with an array of integers for dice and that you handle category names consistently, preventing common runtime bugs.
- Efficient Performance: Because Crystal compiles to native machine code, it's incredibly fast. While performance isn't a major concern for a small set of five dice, this efficiency becomes a significant advantage if you were to expand the logic into a full-fledged game simulation or a more complex analytical engine.
These features combine to create a development experience that is both pleasant and robust, allowing you to focus on the problem's logic rather than wrestling with the language. For a deeper dive into the language's features, explore our complete Crystal programming guide.
How to Structure the Crystal Solution
A good solution starts with a solid plan. We will structure our Crystal code to be modular, readable, and easy to test. Our approach will revolve around a central class, Yacht, with a single public method that dispatches the scoring logic based on the category.
Step 1: Setting Up the Crystal Project
First, let's create a new Crystal project. This is a standard practice that helps organize your files. Open your terminal and run the following commands:
# Create a new project directory
crystal init app yacht_solver
# Navigate into the project directory
cd yacht_solver
# The main source file will be in src/yacht_solver.cr
This command sets up a standard project structure, including a src directory for your source code and a shard.yml file for managing dependencies (though we won't need any external ones for this problem).
Step 2: Designing the Core Logic Flow
Before writing a single line of code, let's visualize the process. The program will take the dice and a category, then route the request to the correct scoring function. This is a perfect use case for a case statement in Crystal.
Here is a high-level flowchart of our algorithm's logic:
● Start (Input: dice, category)
│
▼
┌───────────────────────┐
│ Tally Dice Frequencies│
│ e.g., [1,1,2,3,3] -> │
│ {1=>2, 2=>1, 3=>2} │
└──────────┬────────────┘
│
▼
◆ Select Category ◆
╱ │ ╲
"Ones" "Full House" "Yacht"
│ │ │
▼ ▼ ▼
┌─────────┐ ┌──────────┐ ┌────────┐
│ Sum 1s │ │ Check 3&2│ │ Check 5│
│ │ │ counts │ │ of a kind│
└─────────┘ └──────────┘ └────────┘
│ │ │
└────────────┼───────────┘
│
▼
┌──────────┐
│ Return │
│ Score │
└──────────┘
│
▼
● End
This flow shows a clear separation of concerns. First, we process the dice into a more useful data structure (a frequency map or "tally"). Then, we use the category to decide which specific scoring rule to apply.
Step 3: Implementing the Solution in Crystal
Now, let's translate this design into code. We'll create a Yacht class within the src/yacht_solver.cr file. The code will be fully commented to explain each part.
# src/yacht_solver.cr
class Yacht
# The main public method to calculate the score.
# It takes an array of dice (Int32) and a category (String).
# It returns the calculated score (Int32).
def self.score(dice : Array(Int32), category : String) : Int32
# Tally the dice to get a frequency map.
# For example, [1, 1, 2, 4, 4] becomes {1 => 2, 2 => 1, 4 => 2}
counts = dice.tally
case category
when "ones"
sum_of_a_kind(dice, 1)
when "twos"
sum_of_a_kind(dice, 2)
when "threes"
sum_of_a_kind(dice, 3)
when "fours"
sum_of_a_kind(dice, 4)
when "fives"
sum_of_a_kind(dice, 5)
when "sixes"
sum_of_a_kind(dice, 6)
when "full house"
# A full house has two groups of counts: one of size 3 and one of size 2.
# The values of the counts hash will be [3, 2] or [2, 3].
# Sorting them ensures we can check against a consistent [2, 3].
if counts.values.sort == [2, 3]
dice.sum
else
0
end
when "four of a kind"
# Find the die value that appears 4 or 5 times.
# `find` returns the [key, value] pair or nil.
four_or_more = counts.find { |die, count| count >= 4 }
if four_or_more
# The key (die value) is at index 0 of the pair.
four_or_more[0] * 4
else
0
end
when "little straight"
# A little straight is 1, 2, 3, 4, 5. Sorting makes it easy to check.
if dice.sort == [1, 2, 3, 4, 5]
30
else
0
end
when "big straight"
# A big straight is 2, 3, 4, 5, 6.
if dice.sort == [2, 3, 4, 5, 6]
30
else
0
end
when "yacht"
# A yacht is 5 of a kind. The tally will have only one entry.
if counts.size == 1
50
else
0
end
when "choice"
# Choice is simply the sum of all dice.
dice.sum
else
# Default case for any unknown category.
0
end
end
# Private helper method to calculate the sum for the number categories.
# This avoids code duplication.
private def self.sum_of_a_kind(dice : Array(Int32), kind : Int32) : Int32
# Select only the dice that match the 'kind' and then sum them.
dice.select { |d| d == kind }.sum
end
end
Step 4: A Detailed Code Walkthrough
Let's break down the implementation to understand every decision.
The `Yacht` Class and `score` Method
We define a class Yacht and a class method self.score. Using a class method is ideal here because the scoring logic is stateless; it doesn't need to maintain any information between calls. It simply takes inputs and produces an output.
def self.score(dice : Array(Int32), category : String) : Int32
The type annotations (e.g., Array(Int32)) are a key feature of Crystal. They declare our intent: dice must be an array of 32-bit integers, category must be a string, and the method guarantees it will return a 32-bit integer. This prevents a whole class of bugs.
The Power of `tally`
counts = dice.tally
This single line is the most critical part of our data preparation. The tally method, from the Enumerable module, iterates through the dice array and returns a Hash where keys are the unique elements (the die faces) and values are their frequencies (how many times they appeared). This transforms our raw data into a structured format perfect for pattern matching.
The `case` Statement: The Control Center
case category ... end
The case statement acts as our primary dispatcher. It compares the input category string against each when clause and executes the corresponding block of code. This is far cleaner and more readable than a long chain of if/elsif/else statements.
Handling Number Categories with a Helper
when "ones" ... "sixes"
For the first six categories, the logic is identical: sum the dice of a specific value. To avoid repeating ourselves (a core programming principle known as DRY - Don't Repeat Yourself), we extract this logic into a private helper method: sum_of_a_kind. This makes the main `score` method cleaner and easier to read.
private def self.sum_of_a_kind(...)
This method uses select to filter the array for the desired number and then sum to add them up. It's a beautiful example of functional-style chaining in Crystal.
Complex Category Logic
-
Full House:
if counts.values.sort == [2, 3]. We check the values of our tally hash. A full house must have counts of 3 and 2. We sort the values array to[2, 3]to handle both{val1 => 3, val2 => 2}and{val1 => 2, val2 => 3}with a single check. If it matches, we return the total sum of the dice. -
Four of a Kind:
counts.find { |die, count| count >= 4 }. We usefindto search the tally for an entry where the count is 4 or more (to also handle a Yacht, which qualifies). If found,findreturns the[key, value]pair. We then multiply the key (the die face) by 4 to get the score. -
Straights:
if dice.sort == [1, 2, 3, 4, 5]. The simplest way to check for a straight is to sort the dice and compare the result to the exact array representing that straight. This is both readable and foolproof. -
Yacht:
if counts.size == 1. A Yacht (five of a kind) means all dice are the same. In this case, our tally hash will have only one key-value pair. Checking if the hash's size is 1 is a very elegant way to verify this condition. -
Choice:
dice.sum. This is the simplest category, just the sum of all elements in the array.
This detailed logic is best visualized with a more focused flowchart.
● Input (counts, category)
│
▼
┌──────────────────────┐
│ `case category` block│
└──────────┬───────────┘
│
╭──────────┴───────────╮
│ │
▼ ▼
◆ category is ◆ category is
│ "full house"? │ "four of a kind"?
╰──────────┬───────────╯ ╰──────────┬───────────╯
│ Yes │ Yes
▼ ▼
┌──────────────────────┐ ┌──────────────────────┐
│ Check if counts │ │ Find count >= 4 in │
│ values are {2, 3} │ │ `counts` hash │
└──────────────────────┘ └──────────────────────┘
│ No │ No
▼ ▼
(Check next `when`...) (Check next `when`...)
Alternative Approaches & Performance Considerations
The implemented solution using a case statement and tally is highly idiomatic and readable for Crystal. However, it's always valuable for a developer to consider alternative designs.
Alternative: Hash of Procs/Lambdas
For a more dynamic or extensible system, you could use a Hash where keys are the category names and values are Proc objects that encapsulate the scoring logic for that category.
# Conceptual example of a Proc-based approach
SCORING_LOGIC = {
"yacht" => ->(dice : Array(Int32), counts : Hash(Int32, Int32)) {
counts.size == 1 ? 50 : 0
},
"choice" => ->(dice : Array(Int32), counts : Hash(Int32, Int32)) {
dice.sum
},
# ... other categories
}
def self.score_dynamic(dice, category)
counts = dice.tally
if logic = SCORING_LOGIC[category]?
logic.call(dice, counts)
else
0
end
end
This approach can be useful if you need to add or modify scoring rules at runtime, but it can be slightly less performant and potentially harder to read for a fixed set of rules like in the Yacht game.
Performance Analysis
For this specific problem, with only five dice, performance differences between approaches are negligible. The current solution is extremely fast. Let's analyze its complexity:
dice.tally: This operation is O(N), where N is the number of dice (5).dice.sort: This is O(N log N). For N=5, this is trivial.- Hash lookups and value checks are effectively O(1) because the number of unique keys is small and fixed (max 6).
The overall complexity is dominated by the initial processing steps, which are highly efficient for a small, fixed-size input. The chosen solution provides the best balance of readability, maintainability, and performance.
Pros and Cons of the Chosen Approach
| Pros | Cons |
|---|---|
Highly Readable: The case statement clearly maps categories to logic blocks. |
Less Dynamic: Adding a new category requires modifying the source code of the score method. |
Idiomatic Crystal: Leverages standard library features like tally and case effectively. |
Slightly Verbose: The case statement can get long with many categories. |
| Type-Safe and Performant: Crystal's compiler ensures correctness and generates fast native code. | Tightly Coupled: All scoring logic is contained within one large method. |
Easy to Test: Each when branch can be tested as a separate unit. |
- |
Frequently Asked Questions (FAQ)
Why is `tally` better than manually counting dice with a loop?
The tally method is superior for several reasons. First, it's more concise and expressive; one word replaces an entire loop structure. Second, it's a highly optimized, built-in method, often faster than a manually written loop. Finally, it communicates intent more clearly, making the code easier for other developers to understand at a glance.
How does Crystal's static typing help in this specific problem?
Static typing prevents subtle bugs. For example, it guarantees that the dice variable is always an Array(Int32), so you can't accidentally pass a string or a mixed array. It also ensures the return type is always an Int32, preventing issues in whatever code calls this method. This compile-time safety net is a major advantage for building reliable applications.
Could this solution be adapted to handle a different number of dice, say 7?
Most of the logic would adapt seamlessly. Categories like "Ones" through "Sixes", "Choice", "Four of a Kind", and "Yacht" (which would become "Seven of a Kind") would work by just changing the hardcoded numbers. However, the "Full House" and "Straight" logic is specific to five dice. You would need to define new rules for what constitutes a "Full House" or "Straight" with seven dice and implement that new logic.
What is the purpose of sorting the values for the "Full House" check?
When you call counts.values on a tally for a full house like [3, 3, 5, 5, 5], the result is [2, 3]. For [2, 2, 2, 4, 4], the result is [3, 2]. Since a Hash in Crystal doesn't guarantee order, the values could appear in any order. By sorting the values array to [2, 3], we create a canonical representation that allows us to use a single, simple equality check (== [2, 3]) to validate the condition correctly in all cases.
Is a `class` necessary, or could this be implemented in a `module`?
A module would also be a perfectly valid choice. Since all our methods are class methods (self.score), they don't depend on instance state. Placing them in a module like module YachtScorer would be semantically appropriate. A class is often used by convention to group related functionality, but for stateless utility functions, a module is an excellent alternative.
Why does the "Four of a Kind" logic check for `>= 4` instead of `== 4`?
This is a subtle but important detail for correctness. A "Yacht" (five of a kind) also qualifies as a "Four of a Kind". For example, if the dice are [4, 4, 4, 4, 4], it should score for the "Four of a Kind" category. By checking for a count greater than or equal to 4 (>= 4), our logic correctly handles both cases.
Conclusion: Mastering Logic with Crystal
We have successfully navigated the Yacht game challenge, transforming a set of rules into a clean, efficient, and idiomatic Crystal solution. The journey involved more than just writing code; it was about strategic thinking. We saw how preparing our data with tally simplified the entire problem, how a case statement can elegantly manage complex conditional logic, and how helper methods can promote clean, reusable code.
This kodikra module serves as a fantastic practical exercise in data manipulation and algorithmic thinking. The patterns you've learned here—processing collections, matching patterns, and structuring logic—are fundamental skills that apply to countless real-world programming challenges, from data analysis to game development.
To continue building on these skills, we encourage you to explore more challenges. See how this exercise fits into the broader curriculum by viewing the complete Crystal 3 learning path on kodikra. For a deeper understanding of the language itself, don't forget to consult our in-depth Crystal language resources.
Disclaimer: All code in this article is written for and tested with Crystal version 1.12.0. While the core concepts are stable, method names or behaviors in the standard library could change in future versions of the language.
Published by Kodikra — Your trusted Crystal learning resource.
Post a Comment