1 The Problem
We want a tiny game: the computer thinks of a number between 1 and 100, and the player keeps guessing. After each guess the computer says “too high” or “too low” until the player gets it right. Simple — but it teaches the three things every program does: ask, decide, and repeat.
2 How to Think About It
Before writing any code, picture the game as a loop with a decision inside it. You do not need to know C yet — you just need to know the shape of what happens:
3 The Build — explained part by part
Here is the complete game, split into a header (NumberGuessingGame.h), the testable logic (NumberGuessingGame.c), and a tiny main.c that wires it to real stdin/stdout. Read each part’s plain-English note below it.
#ifndef NUMBER_GUESSING_GAME_H
#define NUMBER_GUESSING_GAME_H
#include <stdio.h>
/* Plays one round: reads guesses (one per line) from `in`, writes prompts and
* feedback to `out`. Returns the number of guesses it took to win, or -1 if
* `in` ran out of input before a correct guess. */
int play_game(int secret, FILE *in, FILE *out);
#endif
#include "NumberGuessingGame.h"
#include <stdlib.h>
#include <string.h>
int play_game(int secret, FILE *in, FILE *out) {
char line[64];
int guesses = 0;
fprintf(out, "I'm thinking of a number between 1 and 100.\n");
while (fgets(line, sizeof(line), in) != NULL) {
int guess = atoi(line);
guesses++;
if (guess == secret) {
fprintf(out, "Correct! You guessed it in %d tries.\n", guesses);
return guesses;
} else if (guess < secret) {
fprintf(out, "Too low.\n");
} else {
fprintf(out, "Too high.\n");
}
}
return -1; /* input ran out without a correct guess */
}
#include "NumberGuessingGame.h"
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
int main(void) {
srand((unsigned)time(NULL));
int secret = 1 + rand() % 100;
int result = play_game(secret, stdin, stdout);
return result >= 0 ? 0 : 1;
}
FILE * parameters instead of reaching for stdin/stdout directly. That is exactly what lets the tests below feed it fake input and capture its output through fmemopen, a real glibc function that turns an in-memory buffer into a genuine FILE * — no temporary files, no real terminal involved.The header/source split —
NumberGuessingGame.h declares the function; NumberGuessingGame.c defines it. This is C’s answer to Java’s single-file classes: the header is the contract, and any other .c file (the real main.c, or the test file) can #include it and link against the compiled logic without ever seeing its source.while (fgets(line, sizeof(line), in) != NULL) — C has no dedicated infinite-loop-with-early-exit idiom the way some languages do, so looping until a read fails (end of input) is the standard C pattern for “keep going until there is nothing left to read.”
atoi(line) — the simplest way to parse a guess, chosen deliberately for a beginner project: unlike
strtol, it gives no way to detect a malformed input (it just returns 0), which is disclosed plainly as a real limitation rather than hidden.
rand() % 100 inside the loop instead of once before it — the secret would change every guess and the game could never be won.play_game starts looping (see main.c).if (guess = secret) by mistake instead of == — a classic C footgun, since assignment is itself a valid expression that silently evaluates to the assigned value.-Wall (as this project does): GCC specifically warns “suggest parentheses around assignment used as truth value” for exactly this mistake.rand() with srand(time(NULL)) — without it, the “random” secret is identical on every single run of the program.srand() exactly once, at the very start of main, before the first rand() call.4 Test & Prove Each Part
How do we know the game works without playing it by hand? We write small tests — each one checks one rule and proves it. C ships no built-in test runner at all (unlike Python’s unittest, Go’s testing, or Java’s JUnit), and this sandbox cannot reach a package registry to pull one in — so this project uses a small, genuinely common real-world practice for solo C utilities: assert()-based checks in a tiny hand-written harness, covered in the course’s Testing lesson as one real option alongside Unity and Check.
#include "NumberGuessingGame.h"
#include <assert.h>
#include <stdio.h>
#include <string.h>
#define RUN(name) do { name(); printf("PASS: %s\n", #name); } while (0)
static void correct_guess_on_first_try_returns_one(void) {
char buf[16];
FILE *out = fmemopen(buf, sizeof(buf), "w");
FILE *in = fmemopen("42\n", 3, "r");
int result = play_game(42, in, out);
fclose(in); fclose(out);
assert(result == 1);
}
static void counts_every_guess_before_the_correct_one(void) {
char outbuf[256];
FILE *out = fmemopen(outbuf, sizeof(outbuf), "w");
FILE *in = fmemopen("10\n50\n42\n", 9, "r");
int result = play_game(42, in, out);
fclose(in); fclose(out);
assert(result == 3);
}
static void running_out_of_guesses_returns_negative_one(void) {
char outbuf[256];
FILE *out = fmemopen(outbuf, sizeof(outbuf), "w");
FILE *in = fmemopen("10\n20\n", 6, "r");
int result = play_game(42, in, out);
fclose(in); fclose(out);
assert(result == -1);
}
static void feedback_says_too_low_and_too_high_correctly(void) {
char outbuf[256] = {0};
FILE *out = fmemopen(outbuf, sizeof(outbuf), "w");
FILE *in = fmemopen("10\n90\n50\n", 9, "r");
play_game(50, in, out);
fclose(in); fclose(out);
assert(strstr(outbuf, "Too low.") != NULL);
assert(strstr(outbuf, "Too high.") != NULL);
assert(strstr(outbuf, "Correct!") != NULL);
}
int main(void) {
RUN(correct_guess_on_first_try_returns_one);
RUN(counts_every_guess_before_the_correct_one);
RUN(running_out_of_guesses_returns_negative_one);
RUN(feedback_says_too_low_and_too_high_correctly);
printf("All tests passed.\n");
return 0;
}
Compile and run with gcc -D_GNU_SOURCE -o test_run NumberGuessingGame.c test_NumberGuessingGame.c && ./test_run. The -D_GNU_SOURCE flag is what exposes fmemopen on glibc — without it the test file will not compile. Note that main.c is deliberately left out of this compile line: it has its own main, which would collide with the test harness’s main in test_NumberGuessingGame.c.
5 The Interface
Even a tiny program has an interface — the way a person interacts with it. Here is its contract, documented plainly, the same way a professional would describe any tool.
What it expects
A number like 42, typed and then Enter pressed.What it returns
"Too low."
"Too high."
"Correct! You guessed it in N tries."6 Run It & Automate It
Save the code as NumberGuessingGame.h / NumberGuessingGame.c / main.c and compile it with gcc — that turns your source directly into a native executable for your machine. No separate runtime needed: the compiled binary runs on its own.
gcc -o guess main.c NumberGuessingGame.c && ./guessThen type a number, press Enter, and follow the “too high / too low” hints until you win.
A CI tool like Jenkins runs the same compile-then-test-then-check-for-leaks steps automatically whenever the code changes — every line below has a plain explanation.
I'm thinking of a number between 1 and 100.
50
Too high.
25
Too low.
37
Too high.
31
Correct! You guessed it in 4 tries.srand((unsigned)time(NULL)); once, before the first rand() call, as main.c does.main.c alone without NumberGuessingGame.c. The header only declares the function; the linker needs the .c file that actually defines it, in the same compile command.main.c together with test_NumberGuessingGame.c — both define main. Compile the real program from main.c + NumberGuessingGame.c, and the tests from NumberGuessingGame.c + test_NumberGuessingGame.c, never all three together.// Jenkinsfile — compiles, tests, and checks for leaks on every change.
pipeline {
agent any
stages {
stage('Get the code') {
// download the latest code
steps { checkout scm }
}
stage('Compile') {
steps {
// confirm a compiler is installed
sh 'gcc --version'
// compile with strict warnings on
sh 'gcc -std=c17 -Wall -Wextra -o app *.c'
}
}
stage('Run the tests') {
steps {
// prints PASS/FAIL, exits non-zero on failure
sh './app'
}
}
stage('Check for memory leaks') {
steps {
// fails the build on any leak or invalid access
sh 'valgrind --error-exitcode=1 --leak-check=full ./app'
}
}
}
post {
success { echo 'All tests passed, no leaks found.' }
failure { echo 'A test or Valgrind check failed — see above.' }
}
}
main.c.FILE * parameters instead of hardcoding stdin/stdout to make code testable; fmemopen for in-memory I/O in tests; and why seeding rand() matters.