Implement High Score System with Persistence in Rust

Mục tiêu: Add a high score system to a Rust quiz game with file persistence using Serde for JSON serialization.


Adding High Score System to Rust Quiz Game

Now that we have the basic quiz game functionality in place, let’s enhance it by adding a high score system. This will involve saving the scores to a file so they persist between game sessions. We’ll create a function to save high scores and later implement functions to load and display them.

Step: Add a function to save high scores to a file

To save high scores, we’ll need to:

  1. Create a data structure to represent high scores
  2. Implement a function to save scores to a file
  3. Handle potential file operations errors

Let’s start by creating a new module for high score functionality.

// src/high_scores/mod.rs
use serde::{Serialize, Deserialize};
use std::fs;
use std::path::Path;

#[derive(Serialize, Deserialize, Debug)]
pub struct HighScore {
    pub name: String,
    pub score: u32,
}

pub fn save_high_scores(scores: Vec<HighScore>, file_path: &str) -> Result<(), std::io::Error> {
    // Serialize the scores to JSON
    let json = serde_json::to_string_pretty(&scores)?;

    // Write to file
    fs::write(Path::new(file_path), json.as_bytes())?;
    Ok(())
}

pub fn load_high_scores(file_path: &str) -> Result<Vec<HighScore>, std::io::Error> {
    if let Ok(contents) = fs::read_to_string(Path::new(file_path)) {
        let scores: Vec<HighScore> = serde_json::from_str(&contents)?;
        return Ok(scores);
    }
    Err(std::io::Error::new(
        std::io::ErrorKind::NotFound,
        "High scores file not found",
    ))
}

pub fn display_high_scores(scores: &Vec<HighScore>) {
    println!("High Scores:");
    for (index, score) in scores.iter().enumerate() {
        println!("{}: {} - {}", index + 1, score.name, score.score);
    }
}

Updating main.rs

We’ll need to modify our main.rs to use this new module:

// main.rs
mod high_scores;

fn main() {
    // ... existing code ...

    // After calculating final score
    let high_scores = high_scores::load_high_scores("high_scores.json").unwrap_or_default();

    // Add current score if it's high enough
    let mut updated_scores = high_scores;
    updated_scores.push(HighScore {
        name: user_name.clone(),
        score: final_score,
    });
    updated_scores.sort_by(|a, b| b.score.cmp(&a.score));
    updated_scores.truncate(10); // Keep top 10 scores

    // Save the updated high scores
    high_scores::save_high_scores(updated_scores, "high_scores.json").unwrap();

    // Display high scores
    high_scores::display_high_scores(&updated_scores);

    // ... rest of your code ...
}

Dependencies

Add these dependencies to your Cargo.toml:

[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

Explanation

  1. Data Structure: We created a HighScore struct that holds a player’s name and their score. This struct implements Serialize and Deserialize traits for easy JSON conversion.
  2. File Operations:
  3. save_high_scores: Takes a vector of HighScore and a file path, serializes the scores to JSON, and writes them to the file.
  4. load_high_scores: Reads from the file, deserializes the JSON back into a vector of HighScore, and returns it.
  5. Display Function: A simple function to print out the high scores in a formatted way.
  6. Integration: In main.rs, after the quiz completes, we:
  7. Load existing high scores
  8. Add the current player’s score
  9. Sort the scores
  10. Keep only the top 10 scores
  11. Save the updated list back to file
  12. Display the updated high scores

Testing

To test this functionality:

  1. Run the game multiple times with different names and scores
  2. Check that the high scores file (high_scores.json) is being created and updated
  3. Verify that only the top 10 scores are kept
  4. Check the formatting of the displayed high scores

Next Steps

Now that we can save high scores, the next tasks will be:

  1. Implementing the function to load high scores
  2. Adding sorting logic to display scores in order
  3. Testing the high score system thoroughly

Further Reading

Implementing High Score Loading and Display in Rust Quiz Game

Mục tiêu: Load and display high scores from a JSON file in a Rust quiz game.


Implementing High Score Loading and Display in Rust Quiz Game

In this task, we’ll implement the functionality to load and display high scores for our Rust Quiz Game. This will involve reading from a file, parsing the data, and displaying it in a user-friendly format.

Understanding the Requirements

Before diving into the code, let’s outline what we need to achieve:

  1. Load High Scores: We need to read high scores from a file. The file format could be JSON or plain text. For this example, we’ll use JSON for better structure.
  2. Display High Scores: Once loaded, we need to display these scores in a formatted way. This could be a sorted list showing the top scores.

Step-by-Step Implementation

1. Define the Data Structure

First, we need to define a data structure to hold high score information. Let’s create a struct called HighScore:

#[derive(Debug, Serialize, Deserialize)]
struct HighScore {
    name: String,
    score: u32,
}

#[derive(Debug, Serialize, Deserialize)]
struct HighScores {
    scores: Vec<HighScore>,
    file_path: String,
}
  • HighScore struct holds a player’s name and their score
  • HighScores struct holds a collection of scores and the file path where they are stored

2. Implement Loading High Scores

We’ll implement a method to load high scores from a file:

use std::fs::File;
use std::io::Read;
use std::path::Path;

impl HighScores {
    fn load(&mut self) -> Result<(), Box<dyn std::error::Error>> {
        // Try to open the file
        let mut file = File::open(&self.file_path)?;

        // Read the contents of the file
        let mut contents = String::new();
        file.read_to_string(&mut contents)?;

        // Parse the JSON
        let scores: Vec<HighScore> = serde_json::from_str(&contents)?;

        // Update our scores vector
        self.scores = scores;

        Ok(())
    }
}
  • This method opens the file, reads its contents, and parses the JSON into a vector of HighScore objects
  • If the file doesn’t exist, we’ll create it with default values in the next step

3. Implement Displaying High Scores

Next, we’ll implement a method to display the high scores:

impl HighScores {
    fn display(&self) {
        println!("High Scores:");
        println!("-----------");

        if self.scores.is_empty() {
            println!("No scores yet!");
            return;
        }

        // Sort the scores in descending order
        let mut sorted_scores = self.scores.clone();
        sorted_scores.sort_by(|a, b| b.score.cmp(&a.score));

        for (index, score) in sorted_scores.iter().enumerate() {
            println!("{}: {} - {}", index + 1, score.name, score.score);
        }

        println!("-----------");
    }
}
  • This method prints out the high scores in a formatted way
  • Scores are sorted in descending order before being displayed

4. Error Handling

The methods return Result types to handle potential errors gracefully. This is important for file operations where things can go wrong (file not found, permission issues, etc.).

5. Testing the Implementation

Let’s create a test function to verify our implementation:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_high_scores() {
        let mut high_scores = HighScores {
            scores: vec![HighScore {
                name: "Player1".to_string(),
                score: 100,
            }],
            file_path: "test_scores.json".to_string(),
        };

        // Save initial scores
        high_scores.save().unwrap();

        // Load scores
        high_scores.load().unwrap();

        // Display scores
        high_scores.display();
    }
}
  • This test creates a HighScores instance, saves it, loads it back, and displays it
  • You should see the scores printed to the console when running the test

Next Steps

After implementing this task, you should:

  1. Integrate the high score system with the main game loop
  2. Implement the saving functionality
  3. Add sorting logic for better score display
  4. Test the system thoroughly with different scenarios

Further Reading

To learn more about the concepts used here, you can explore these topics:

  1. Rust File Handling
  2. Rust Error Handling
  3. Serde JSON Serialization
  4. Rust Structs and Enums

These resources will help you understand the underlying concepts and improve your implementation.

Implementing Sorting Logic for High Scores in Rust

Mục tiêu: A step-by-step guide to implementing sorting logic for high scores in Rust, including struct definition, sorting functions, and display functionality.


Implementing Sorting Logic for High Scores in Rust

Now that we’ve implemented functions to save and load high scores, the next step is to add sorting logic to display the top scores. This will allow players to see the highest achievements in an organized manner.

Step-by-Step Approach

  1. Define a Struct for High Scores We’ll create a struct to represent high scores. This struct will include the player’s name and their score.
  2. Implement Sorting Function We’ll create a function that takes a vector of HighScore instances and returns a new vector sorted in descending order based on scores.
  3. Display Sorted Scores We’ll implement a function to display the sorted high scores in a user-friendly format.
  4. Integrate with Existing Code We’ll show how to integrate this sorting logic with the existing high score loading and saving functions.

Code Implementation

// Define a struct to represent high scores
#[derive(Debug, PartialEq, Eq)]
struct HighScore {
    name: String,
    score: u32,
}

// Implement sorting logic for high scores
fn sort_high_scores(scores: Vec<HighScore>) -> Vec<HighScore> {
    // Sort scores in descending order
    let mut sorted_scores = scores;
    sorted_scores.sort_by(|a, b| b.score.cmp(&a.score));
    sorted_scores
}

// Display high scores in a formatted way
fn display_high_scores(scores: Vec<HighScore>) {
    println!("\n--- High Scores ---");
    println!("Rank | Name          | Score");
    println!("----------------------------");

    for (rank, score) in scores.iter().enumerate() {
        println!(
            "{0}   | {1:<10} | {2}",
            rank + 1,
            score.name,
            score.score
        );
    }
    println!("----------------------------\n");
}

Explanation

  • HighScore Struct: This struct holds the player’s name and their score. The #[derive(Debug, PartialEq, Eq)] attribute allows us to compare and display instances of this struct.
  • Sorting Function: The sort_high_scores function takes a vector of HighScore and sorts it in place using sort_by. The sorting is done in descending order based on the score.
  • Display Function: The display_high_scores function prints the high scores in a tabular format. It includes ranks, names, and scores, making it easy to read and understand.

Integration with Existing Code

Assuming you have functions load_high_scores() and save_high_scores(), you can integrate the sorting logic as follows:

fn main() {
    // Load existing high scores
    let mut high_scores = load_high_scores();

    // Sort the high scores
    let sorted_scores = sort_high_scores(high_scores);

    // Display the sorted high scores
    display_high_scores(sorted_scores);
}

Best Practices

  • Error Handling: Make sure to handle potential errors when loading and saving files.
  • Testing: Test the sorting logic with different datasets to ensure correctness.
  • Code Organization: Keep these functions in a separate module (e.g., utils/mod.rs) for better code organization.

To deepen your understanding of Rust’s sorting capabilities:

  1. Rust Sorting Methods
  2. Custom Sorting with sort_by
  3. Rust Structs and Enums

By following this approach, you’ll have a robust high score system that displays scores in a clear and organized manner.

Testing the High Score System in Rust Quiz Game

Mục tiêu: Ensuring the high score system in a Rust quiz game functions correctly, including saving, loading, and displaying scores, as well as handling edge cases.


Testing the High Score System in Rust Quiz Game

Testing is a critical part of software development, and for the high score system in our Rust Quiz Game, we need to ensure that:

  1. Scores are saved to and loaded from the file correctly
  2. High scores are properly sorted and displayed
  3. The system handles multiple playthroughs without data corruption
  4. Edge cases like empty files or invalid data are managed

Let’s break down how to test this system thoroughly.

1. Setting Up for Testing

Before we start testing, let’s make sure we have the following components in place:

// high_scores.rs
use std::fs::{File, OpenOptions};
use std::io::{Read, Write};
use std::path::Path;

pub struct HighScores {
    file_path: String,
}

impl HighScores {
    pub fn new(file_path: &str) -> HighScores {
        HighScores {
            file_path: file_path.to_string(),
        }
    }

    pub fn save_score(&self, score: i32) -> std::io::Result<()> {
        let mut file = OpenOptions::new()
            .write(true)
            .create(true)
            .append(true)
            .open(&self.file_path)?;

        file.write_all(format!("{}\n", score).as_bytes())?;
        Ok(())
    }

    pub fn load_scores(&self) -> std::io::Result<Vec<i32>> {
        if !Path::new(&self.file_path).exists() {
            return Ok(Vec::new());
        }

        let mut file = File::open(&self.file_path)?;
        let mut contents = String::new();
        file.read_to_string(&mut contents)?;

        let scores: Vec<i32> = contents
            .lines()
            .map(|line| line.trim().parse::<i32>().unwrap_or(0))
            .collect();

        Ok(scores)
    }

    pub fn display_scores(&self) -> String {
        let scores = self.load_scores().unwrap_or_default();
        let sorted_scores = scores.iter().cloned().filter(|s| *s > 0).sorted().rev().collect::<Vec<_>>();

        let mut display = String::new();
        display.push_str("High Scores:\n");
        for (index, score) in sorted_scores.iter().enumerate() {
            display.push_str(&format!("{}: {}\n", index + 1, score));
        }
        display
    }
}

This implementation includes:

  • Saving scores to a file
  • Loading scores from a file
  • Displaying sorted scores in descending order
  • Basic error handling

2. Unit Testing

Let’s write some unit tests to verify individual components:

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs::remove_file;

    #[test]
    fn test_save_and_load_score() {
        let tmp_file = "test_scores.txt";
        let high_scores = HighScores::new(tmp_file);

        // Save some test scores
        high_scores.save_score(100).unwrap();
        high_scores.save_score(50).unwrap();

        // Load and verify scores
        let loaded_scores = high_scores.load_scores().unwrap();
        assert_eq!(loaded_scores, vec![100, 50]);

        // Clean up
        remove_file(tmp_file).unwrap();
    }

    #[test]
    fn test_display_scores() {
        let tmp_file = "test_scores.txt";
        let high_scores = HighScores::new(tmp_file);

        // Save test scores
        high_scores.save_score(150).unwrap();
        high_scores.save_score(75).unwrap();

        // Display scores
        let display = high_scores.display_scores();
        println!("{}", display);
        assert!(display.contains("1: 150"));
        assert!(display.contains("2: 75"));

        remove_file(tmp_file).unwrap();
    }

    #[test]
    fn test_empty_file_handling() {
        let tmp_file = "empty_test.txt";
        let high_scores = HighScores::new(tmp_file);

        // Try to load from non-existent file
        let loaded_scores = high_scores.load_scores().unwrap();
        assert_eq!(loaded_scores, Vec::new());

        // Save a score
        high_scores.save_score(200).unwrap();

        // Load again
        let loaded_after_save = high_scores.load_scores().unwrap();
        assert_eq!(loaded_after_save, vec![200]);

        remove_file(tmp_file).unwrap();
    }
}

These tests cover:

  • Basic save/load functionality
  • Display formatting
  • Handling of empty/non-existent files

3. Integration Testing

Integration testing involves testing the high score system as part of the whole application. Here’s how you can test it:

  1. Play through the game once and get a score
  2. Save the score
  3. Play again with a different score
  4. Verify that both scores appear in the high scores list
  5. Repeat multiple times with different scores

You can simulate this in code:

fn main() {
    let mut scores = vec![];

    // Simulate multiple playthroughs
    scores.push(150);
    scores.push(120);
    scores.push(200);
    scores.push(90);

    let high_scores = HighScores::new("high_scores.txt");

    // Save all scores
    for score in scores {
        high_scores.save_score(score).unwrap();
    }

    // Display the high scores
    println!("{}", high_scores.display_scores());
}

4. Testing Edge Cases

  1. Empty File: Verify that if the high scores file is empty, the system handles it gracefully without crashing.
  2. Invalid Data: If somehow non-numeric data gets into the file, ensure the system ignores it or handles it appropriately.
  3. Large Number of Scores: Test with a large number of scores to ensure performance remains acceptable.
  4. Zero or Negative Scores: Verify that only positive scores are saved and displayed.

5. User Interaction Testing

After implementing the high score system, test it with real user interaction:

  1. Play the quiz game
  2. Enter different answers to get various scores
  3. After each game, check if the high scores are updated correctly
  4. Verify that the display shows the correct scores in the right order

6. Automated Testing

You can create a test script that runs multiple instances of the game and saves random scores to verify the system’s robustness:

use rand::Rng;
use std::thread;
use std::time::Duration;

fn test_robustness() {
    let mut rng = rand::thread_rng();

    for _ in 0..10 {
        let score = rng.gen_range(1..=200);
        let high_scores = HighScores::new("high_scores.txt");
        high_scores.save_score(score).unwrap();

        // Small delay between saves
        thread::sleep(Duration::from_millis(100));
    }

    let high_scores = HighScores::new("high_scores.txt");
    println!("Final high scores:\n{}", high_scores.display_scores());
}

7. Cleaning Up

After testing, make sure to clean up any test files you created:

fn cleanup() {
    let _ = remove_file("high_scores.txt");
    let _ = remove_file("test_scores.txt");
    let _ = remove_file("empty_test.txt");
}

Conclusion

Testing the high score system involves:

  1. Unit testing individual components
  2. Integration testing within the whole application
  3. Testing edge cases and boundary conditions
  4. Automated testing for robustness
  5. User interaction testing
  6. Proper cleanup of test files

By following this structured approach, you can ensure that your high score system works reliably and as expected.

Further Reading