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 TypeScript yet — you just need to know the shape of what happens:
3 The Build — explained part by part
Here is the complete game. Read each part’s note below — you should understand the whole thing from the notes alone.
import * as readline from "node:readline";
import { stdin, stdout } from "node:process";
import { randomInt } from "node:crypto";
// ask prints a prompt and waits for one line of input, pulling it from the
// readline interface's own async iterator rather than question()/promises —
// the one readline API that does not drop a line when several prompts are
// answered back-to-back, typed by hand or piped in from a file. It resolves
// to null once there is nothing left to read (stdin closed), so a loop can
// stop cleanly instead of spinning on an empty answer forever.
function makeAsk(rl: readline.Interface) {
const it = rl[Symbol.asyncIterator]();
return async (prompt: string): Promise<string | null> => {
stdout.write(prompt);
const { value, done } = await it.next();
return done ? null : value;
};
}
// playGame is a testable version of the game: hand it the secret number and
// a list of guesses, and it returns how many tries it took (or null if the
// secret was never guessed).
export function playGame(secret: number, guesses: number[]): number | null {
let taken = 0;
for (const guess of guesses) {
taken += 1;
if (guess === secret) {
return taken;
}
}
return null;
}
async function main(): Promise<void> {
const rl = readline.createInterface({ input: stdin, terminal: false });
const ask = makeAsk(rl);
// The computer secretly picks a whole number from 1 to 100.
const secretNumber = randomInt(1, 101);
let guessesTaken = 0;
console.log("I am thinking of a number between 1 and 100.");
while (true) {
const line = await ask("Your guess: ");
if (line === null) {
console.log("\nNo more input — goodbye.");
break;
}
const guess = Number(line.trim());
guessesTaken += 1;
if (guess < secretNumber) {
console.log("Too low. Try a bigger number.");
} else if (guess > secretNumber) {
console.log("Too high. Try a smaller number.");
} else {
console.log(`Correct! You got it in ${guessesTaken} guesses.`);
break;
}
}
rl.close();
}
if (require.main === module) {
main();
}randomInt(1, 101) picks a whole number from 1 up to (but not including) 101 — i.e. 1 to 100.function playGame(secret, guesses): number | null — a testable version of the game, pulled out of the interactive loop exactly like the Python/Go versions. Its return type,
number | null, is TypeScript’s union type: “either a number of guesses, or nothing at all” — and the compiler forces every caller to handle both cases.const it = rl[Symbol.asyncIterator]() — pulling lines from the readline interface’s own async iterator, one
await it.next() per prompt, is what lets several “Your guess:” prompts in a row answer correctly even when every guess is piped in at once.if (line === null) break; — when the input runs out (end of file), the iterator reports “done” and we exit the loop instead of spinning forever on an empty answer.
Math.random() for the secret number.Math.random() is fine for a game, but it is not reproducibly uniform across its whole range and is never appropriate once “random” matters for security (see the password generator project). crypto.randomInt costs nothing extra and builds the right habit.done and break out of the loop — otherwise a piped-in file with too few lines causes an infinite loop with no new prompts, since an exhausted iterator keeps resolving instead of throwing.number when the function can also return “no answer”.number | null (or number | undefined) so the compiler reminds every caller to check before using the result.4 Test & Prove Each Part
How do we know this works? We pull the real logic into small, plain functions and check each one against cases we already know the answer to.
null, not a crashimport { test } from "node:test";
import assert from "node:assert/strict";
import { randomInt } from "node:crypto";
import { playGame } from "./number-guessing-game";
test("correct on the first try takes 1 guess", () => {
assert.equal(playGame(42, [42]), 1);
});
test("correct on the third guess returns 3", () => {
assert.equal(playGame(50, [10, 90, 50]), 3);
});
test("never guessing it returns null, not a crash", () => {
assert.equal(playGame(7, [1, 2, 3]), null);
});
test("the secret number is always between 1 and 100", () => {
for (let i = 0; i < 1000; i++) {
const n = randomInt(1, 101);
assert.ok(n >= 1 && n <= 100);
}
});Compile with npx tsc then run node --test guessing_game.test.js. Because playGame takes the secret and the list of guesses as plain arguments, the tests never have to deal with real randomness or real typed input — they hand it fixed numbers and check the return value.
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. Try a bigger number."
"Too high. Try a smaller number."
"Correct! You got it in N guesses."6 Run It & Automate It
Save the code as guessing_game.ts, compile with npx tsc, and run with node guessing_game.js — or run it directly with npx tsx guessing_game.ts while you are experimenting.
npx tsc guessing_game.ts && node guessing_game.jsKeep guessing until you find the secret number between 1 and 100.
A CI tool like Jenkins runs the type-checker and tests automatically whenever the code changes — every line below has a plain explanation.
I am thinking of a number between 1 and 100.
Your guess: 50
Too low. Try a bigger number.
Your guess: 75
Too high. Try a smaller number.
Your guess: 63
Correct! You got it in 3 guesses.done (see the code above).NaN is never equal to the secret number, so the game never endsguess become NaN, which is never <, >, or === anything — including itself. Type a whole number.// Jenkinsfile — runs the type-checker and tests automatically every time the code changes.
pipeline {
agent any // run on any available machine
stages {
stage('Get the code') {
steps { checkout scm } // download the latest code
}
stage('Set up Node') {
steps {
sh 'node --version' // confirm Node is installed
sh 'npm install -D typescript @types/node' // zero runtime deps — just the compiler and its Node types
}
}
stage('Type-check and test') {
steps {
sh 'npx tsc --noEmit' // catch type errors before anything runs
sh 'npx tsc' // compile to plain JavaScript
sh 'node --test guessing_game.test.js' // Node's built-in test runner, no extra install needed
}
}
}
post {
success { echo 'All tests passed.' }
failure { echo 'A test failed — look above.' }
}
}
You have a working number guessing game. Extend it:
- Limit the attempts. End the game after 10 wrong guesses. (Teaches: a counter plus an early exit.)
- Add difficulty levels. Let the player choose a range like 1–1000. (Teaches: parameterising
randomInt.) - Give a “warmer/colder” hint. Compare how close each guess is to the last one. (Teaches: keeping extra state between loop iterations.)
- Play again. After a win, ask “Play again? (y/n)” and restart. (Teaches: wrapping the whole game in an outer loop.)
playGame takes plain data, not live input), TypeScript’s union types for “a value or nothing”, and crypto.randomInt for real randomness. Related reference: Union & Intersection Types, Type Narrowing.