4
\$\begingroup\$

This is a follow up to my previous Code Review, and I have incorporated feedback I received in this revision of the code, along with some other improvements.

I would appreciate your feedback on this code, specifically the practice of placing project-wide constants in config.h, the count_missing_letters method which uses a function pointer to print the word state, as well as my getchar loop in main.c, where I am now continuing on invalid input and consuming white-space characters.

Is there any cleaner way to write count_missing_letters which accomplishes the task of being easy to use as well as not having its code repeated. I opted to keep one function so that the single loop performs two different checks in one go, and the use of a function pointer decouples what happens on each iteration - unsure if this is 'idiomatic' C.

Below are the source files and the CMakeLists file (which includes many runtime Clang sanitizers enabled). Alternatively, the code can be easily compiled with the following: cc *.c -o hangman && ./hangman

main.c

/**
 *                      * Hangman in C *
 * O(1) lookup using pointers to 26 letters which each have a
 * state. A letter is either empty, or the letter itself.
 * I was inspired by seeing many other Hangman implementations which
 * relied on a multiple layers of iteration, this aims to be 'simple'
 * and 'idiomatic', by using a different approach.
 *
 * @version 2.0
 * @date 1/18/19
 * @author Faraz Fazli
 */

#include <stdio.h>
#include <string.h>
#include <ctype.h>
#include <stdlib.h>
#include "rng.h"
#include "utils.h"
#include "config.h"

// Returns length of an array
#define len(x) (((sizeof(x)))/(sizeof((x)[0])))

int main() {
    char letters[ALPHABET_SIZE];
    int tries = 0;
    rng_init();
    memset(letters, HIDDEN_LETTER, ALPHABET_SIZE);
    size_t total_elems = len(words);

    char *word = words[rng_to(total_elems)];
    size_t word_len = strlen(word); // excludes NUL
    size_t word_size = word_len + 1; // includes NUL

    char **word_to_guess = malloc(word_size * sizeof(*word_to_guess));

    // Link letters in word to 'letters' array
    for (size_t i = 0; i < word_len; i++) {
        word_to_guess[i] = &letters[dst_from_a(word[i])];
    }

    size_t num_prev_missing = word_len;
    count_missing_letters(word_to_guess, print_char);
    fputs("\nPick a letter: ", stdout);

    int chosen_letter;
    while ((chosen_letter = getchar()) != EOF) {
        // Consume newline and other white-space characters
        if (isspace(chosen_letter)) {
            continue;
        }

        if (!isalpha(chosen_letter)) {
            puts("Please enter a valid letter.");
            continue;
        }

        chosen_letter = tolower(chosen_letter);
        size_t letter_pos = dst_from_a(chosen_letter);
        if (letters[letter_pos] != (char) HIDDEN_LETTER) {
            puts("Please pick a different letter");
            continue;
        }

        letters[letter_pos] = (char) chosen_letter;

        size_t num_missing = count_missing_letters(word_to_guess, print_char);
        if (num_missing == num_prev_missing) {
            tries++;
        }
        num_prev_missing = num_missing;

        if (num_missing == 0) {
            puts("-> YOU WIN!");
            break;
        }

        puts("");
        int tries_left = TOTAL_TRIES - tries;
        print_hangman(tries_left);
        if (tries_left > 0) {
            printf("\nTries Remaining: %d\n", tries_left);
            fputs("Pick a letter: ", stdout);
        } else {
            puts("No tries left! Game Over!");
            break;
        }
    }
    free(word_to_guess);
}

config.h

#ifndef HANGMAN_CONFIG_H
#define HANGMAN_CONFIG_H
/**
 * Use enum to replace "magic numbers" instead of #define or const
 * Ref: Practice of Programming, p.21
 */
enum {
    ALPHABET_SIZE = 26,
    TOTAL_TRIES = 10,
    HIDDEN_LETTER = '_',
};

static char *words[] = {"racing", "magic", "bow", "racecar"};

#endif //HANGMAN_CONFIG_H

utils.c

#include <stdio.h>
#include <stdlib.h>
#include "config.h"

void print_hangman(int tries_left) {
    if (tries_left > 7) {
        return;
    }
    switch (tries_left) {
        case 7:
            puts("┏━━━╤━");
            puts("┃┃    ");
            puts("┃┃");
            puts("┃┃");
            puts("┃┃");
            puts("┻┻━━━━━━━");
            break;
        case 6:
            puts("┏━━━╤━");
            puts("┃┃  O ");
            puts("┃┃");
            puts("┃┃");
            puts("┃┃");
            puts("┻┻━━━━━━━");
            break;
        case 5:
            puts("┏━━━╤━");
            puts("┃┃  O ");
            puts("┃┃  | ");
            puts("┃┃    ");
            puts("┃┃    ");
            puts("┻┻━━━━━━━");
            break;
        case 4:
            puts("┏━━━╤━");
            puts("┃┃  O ");
            puts("┃┃ ╲| ");
            puts("┃┃    ");
            puts("┃┃    ");
            puts("┻┻━━━━━━━");
            break;
        case 3:
            puts("┏━━━╤━");
            puts("┃┃  O ");
            puts("┃┃ ╲|╱");
            puts("┃┃    ");
            puts("┃┃    ");
            puts("┻┻━━━━━━━");
            break;
        case 2:
            puts("┏━━━╤━");
            puts("┃┃  O ");
            puts("┃┃ ╲|╱");
            puts("┃┃  | ");
            puts("┃┃    ");
            puts("┻┻━━━━━━━");
            break;
        case 1:
            puts("┏━━━╤━");
            puts("┃┃  O ");
            puts("┃┃ ╲|╱");
            puts("┃┃  | ");
            puts("┃┃ ╱  ");
            puts("┻┻━━━━━━━");
            break;
        case 0:
            puts("┏━━━╤━");
            puts("┃┃  O ");
            puts("┃┃ ╲|╱");
            puts("┃┃  | ");
            puts("┃┃ ╱ ╲");
            puts("┻┻━━━━━━━");
            break;
    }
}

void print_char(char char_to_print) {
    putchar(char_to_print);
    putchar(' ');
}

size_t count_missing_letters(char **word_to_guess, const void(on_each(char))) {
    size_t num_missing = 0;
    while (*word_to_guess) {
        if (on_each != NULL) {
            on_each(**word_to_guess);
        }
        if (**word_to_guess++ == HIDDEN_LETTER) {
            num_missing++;
        }
    }
    return num_missing;
}

size_t dst_from_a(int letter) {
    return (size_t) abs(letter - 'a');
}

utils.h

#ifndef HANGMAN_UTILS_H
#define HANGMAN_UTILS_H

#include <stdlib.h>

/**
 * Prints "hangman" ascii art
 * @param tries_left - must be <= 7 to display
 */
void print_hangman(int tries_left);

/**
 * Prints character, followed by a space
 * @param char_to_print
 */
void print_char(char char_to_print);

/**
 * Prints the state of each letter and counts the number of missing letters
 * Optionally calls a function with each character read
 * @param word_to_guess - word being guessed (array of pointers)
 * @param on_each - optional function to call on each iteration
 * @return underscore count
 */
size_t count_missing_letters(char **word_to_guess, void(on_each(char)));

/**
 * Returns the distance from 'a'
 * @param letter 'a' to 'z' (must be lower case)
 * @return 0 through 25
 */
size_t dst_from_a(int letter);

#endif //HANGMAN_UTILS_H

rng.c

#include "rng.h"
#include <stdlib.h>
#include <time.h>

void rng_init(void) {
    srand((unsigned int) time(NULL));
}

size_t rng_to(size_t max) {
    return (unsigned) rand() / ((unsigned) RAND_MAX / max + 1u);
}

rng.h

#ifndef HANGMAN_RNG_H
#define HANGMAN_RNG_H

#include <stdlib.h>

/**
 * Initializes Random Number Generator
 * Note: RNG is based on the current time and thus does not produce secure values.
 * This is intentional, as the RNG is solely used to select a random current word.
 */
void rng_init(void);

/**
 * Helper method for Random Number Generation
 * @param max - max number
 * @return between 0 to max
 */
size_t rng_to(size_t max);

#endif //HANGMAN_RNG_H

CMakeLists.txt

# Improved version adapted from https://codereview.stackexchange.com/a/210770/78786
cmake_minimum_required(VERSION 3.13)
project(Hangman C)

add_executable(${CMAKE_PROJECT_NAME} main.c utils.c utils.h rng.c rng.h config.h)

set(CMAKE_C_COMPILER clang)
target_compile_features(${CMAKE_PROJECT_NAME} PRIVATE c_std_99)
target_compile_options(${CMAKE_PROJECT_NAME} PRIVATE
        $<$<C_COMPILER_ID:Clang>:
        -Weverything
        -fsanitize=undefined,integer,implicit-conversion,nullability,address,leak,cfi
        -flto
        -fvisibility=default>)
target_link_options(${CMAKE_PROJECT_NAME} PRIVATE
        $<$<C_COMPILER_ID:Clang>:
        -fsanitize=undefined,integer,implicit-conversion,nullability,address,leak,cfi
        -flto>)
\$\endgroup\$
2
\$\begingroup\$

This code is not bad at all, and I like the new ASCII art. Here are some ideas on how to improve it further:

Don't confuse the reader

By grouping things together in an enum it's true that it eliminates "magic numbers" but it also tends to mislead the reader into thinking that these items are related. In this case, there are really three independent constants whose only relation is that they are all used in this game. I'd use const for this and use the appropriate types for each, since ALPHABET_SIZE should probably be size_t, HIDDEN_LETTER a char, etc.

Consider reworking the interface

Right now, there is not much separation of concerns. The main program knows everything about every piece of the program. That works, but it might be nicer to separate things a little more. I'd rename config.h to dictionary.h and have a function named get_random_word:

const char *get_random_word() {
    static const char *words[] = {"racing", "magic", "bow", "racecar"};
    static const size_t word_count = sizeof(words)/sizeof(words[0]);
    return words[rng_to(word_count)];
}

That eliminates the need for main to have the macro, and removes the need for it to know anything about the random number generation code.

Consider different data structures

The code relies on a number of related data structures, letters, words and word_to_guess. I would alter their use a bit. First, I'd hide words completely, as shown above. Right now, the actual underlying alphabet is implicit rather than explicit. It assumes that the alphabet consists of ALPHABET_SIZE contiguous characters beginning from 'a'. This works for English and an ASCII encoding, but not for EBCDIC encodings and not for other languages such as Spanish, French or German. Instead, I'd suggest that there could be an explicit alphabet string associated with the previously mentioned dictionary.h. It could be const. Second, one could employ a bool array of the same length to keep track of which letters of the alphabet had been guessed. This would then be the only data structure that would need to be modified during game play.

An example

If we isolate dictionary things to the dictionary, it might look like this:

const char *dict_init () {
    static const char *alphabet = "abcdefghijklmnopqrstuvwxyz";
    rng_init();
    return alphabet;
}

const char *get_random_word() {
    static const char *words[] = {"racing", "magic", "bow", "racecar"};
    static const size_t word_count = sizeof(words)/sizeof(words[0]);
    return words[rng_to(word_count)];
}

Now from within main we might have this:

int main() {
    const char *alphabet = dict_init();
    bool *guessed = calloc(strlen(alphabet), sizeof(bool));
    int tries = 10;
    const char *word = get_random_word();
    size_t word_len = strlen(word);
    bool playing = true;

    while (playing) {
        display_word(word, word_len, alphabet, guessed);
        fputs("\nPick a letter: ", stdout);
        int chosen_letter;
        for (chosen_letter = tolower(getchar()); isspace(chosen_letter); chosen_letter = tolower(getchar())) 
        { }
        if (chosen_letter == EOF) {
            playing = false;
            continue;
        }
        const char *target = strchr(alphabet, chosen_letter);
        if (target == NULL) {
            puts("Please enter a valid letter.");
            continue;
        }
        if (guessed[target - alphabet]) {
            puts("Please pick a different letter");
            continue;
        }
        guessed[target - alphabet] = true;
        // is this letter in the word to be guessed?
        if (strchr(word, *target) != NULL) {
            if (display_word(word, word_len, alphabet, guessed)) {
                printf("\nTries Remaining: %d\n", tries);
            } else {
                puts("-> YOU WIN!");
                playing = false;
            }
        } else {  // guessed letter not in target word
            playing = print_hangman(--tries);
        }
    }
    free(guessed);
}

Note that I've used strchr to see if the character is within the alphabet. I've also modified your print_hangman to return true if there are guesses left and added this function:

size_t display_word(const char *word, const size_t word_len, const char *alphabet, const bool *guessed) {
    size_t hidden = word_len;
    for (size_t i=0; i < word_len; ++i) {
        bool revealed = guessed[strchr(alphabet, word[i]) - alphabet];
        if (revealed) {
            putchar(word[i]);
            --hidden;
        } else {
            putchar('_');
        }
        putchar(' ');
    }
    return hidden;
}

It's not terribly efficient in terms of runtime performance, but it doesn't matter much since it will be fast enough for a human player.

\$\endgroup\$
  • \$\begingroup\$ Thanks for the advice. Can you please explain more about the last point regarding making an alphabet string and keeping track of letters with a bool array? What would that look like? \$\endgroup\$ – Faraz Jan 18 at 16:17
  • 1
    \$\begingroup\$ I've updated my answer to show what I mean. \$\endgroup\$ – Edward Jan 18 at 17:01
2
\$\begingroup\$

Minor ideas:

Repetitive calls

This is an alternative idea, not a recommendation.

A way to avoid repeated calls to puts(), and still maintain code "art", use string literal concatenation.

        puts(
            "┏━━━╤━\n"
            "┃┃\n"
            "┃┃\n"
            "┃┃\n"
            "┃┃\n"
            "┻┻━━━━━━━");

Note an optimizing compiler may join the original puts() together anyways.

Code could put the 8 strings in an array of strings const char *art[8] = {...}; and then use art[tries_left] rather than a switch.

As with such style issues: code to your group's coding standards.

Type naming

Rather than unsigned int and unsigned in code, use one of them.

\$\endgroup\$
  • \$\begingroup\$ I am curious to hear your thoughts on count_missing_letters. Is this a clean way to express what is happening? \$\endgroup\$ – Faraz Jan 18 at 16:22
  • 1
    \$\begingroup\$ @Faraz I found count_missing_letters(char **word_to_guess, ...) confusing from the start. Perhaps it is the loose usage of the word here. For me a word would be a char *. Here word is a list of pointer to a letter. Consider Edward idea of "employ a bool array of the same length to keep track of which letters of the alphabet had been guessed". IMO, the word to guess should be const and variables descend from that. Consider a struct hangman { const char *word_to_guess; bool *guessed; size_t word_length; size_t guessed_right_count; size_t guess_count }. \$\endgroup\$ – chux - Reinstate Monica Jan 18 at 17:47

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.