Easy Games in Python: 5 Terminal Games and 2 Tkinter Games

Small games make Python easier to understand because every branch changes something you can see, such as a score, a hidden word, or the next turn.

I ran all seven games here against valid and invalid actions, then kept the parts that teach a reusable idea instead of asking you to trust a block of source code.

Choose a game by the Python skill you want to practise

Do not begin with the longest program.

Choose the smallest game that exercises the concept you are learning, understand how it stores state, and make one rule change after the supplied version works.

GameMain Python ideaUseful first change
Number guessingValidation, comparisons, and a while loopLimit the player to five valid guesses
Rock paper scissorsTuples, dictionaries, and score stateEnd the match when either player reaches five
QuizStructured data, loops, and normalizationAccept several correct spellings
HangmanSets, string display, and stop conditionsSeparate correct and incorrect guesses
Tic-tac-toeList indexes, functions, and board stateMove the win check into unit tests
Tkinter guessingCallbacks and widget stateDisable Check after the correct guess
Tkinter tic-tac-toeEvent-driven state and reset logicHighlight the winning three buttons

The five terminal games need only Python 3. The two graphical games use Tkinter from the standard library and need a computer with a desktop session.

1. Number guessing game

Number guessing is the cleanest starting point because the program repeats one decision until the player reaches a known target.

The important state is only the secret number and the count of valid guesses.

How the feedback loop works

The call to randint() chooses the target once, before the loop starts.

If that call lived inside the loop, the target could change after every guess and the feedback would stop making sense.

  1. Read the player’s text and remove surrounding spaces with strip().
  2. Reject non-digits and values outside 1 through 20 before changing the guess count.
  3. Compare a valid number with the target and print low, high, or correct feedback.
  4. Use break only after the correct guess so the loop has one clear exit.

Complete number guessing code

The validation branch appears before int() is used on untrusted text. That order prevents a ValueError and keeps bad input from counting as an attempt.

from random import randint

target = randint(1, 20)
guesses = 0

while True:
    raw = input("Guess a number from 1 to 20: ").strip()

    if not raw.isdigit() or not 1 <= int(raw) <= 20:
        print("Enter a whole number from 1 to 20.")
        continue

    guess = int(raw)
    guesses += 1

    if guess < target:
        print("Too low.")
    elif guess > target:
        print("Too high.")
    else:
        word = "guess" if guesses == 1 else "guesses"
        print(f"You got it in {guesses} {word}.")
        break

What the validation test proves

I entered abc and 0 before making two valid guesses.

Both invalid values produced the correction message, while the finished game reported two guesses rather than four.

Enter a whole number from 1 to 20.
Enter a whole number from 1 to 20.
Too low.
You got it in 2 guesses.

Notice that guesses increases only after the range check.

Move that line above validation and the scoreboard begins measuring keyboard mistakes instead of game attempts.

Tested Python number guessing game showing input validation, low and high feedback, and completion
The game rejects invalid input before it counts a guess.

A useful next change is a maximum-attempt rule. Add a limit around valid guesses, then decide what the player should see when the limit is reached.

2. Rock paper scissors with a rule dictionary

Rock paper scissors looks simple until the win logic turns into repeated conditions.

A dictionary keeps the relationship readable by storing the choice each move defeats.

How one lookup decides the round

The beats dictionary answers a precise question.

Given the player’s choice, which computer choice does it defeat?

  1. Store the three accepted choices in one tuple.
  2. Reject anything outside that tuple before asking the computer to choose.
  3. Handle a draw before checking the dictionary because equal choices defeat neither side.
  4. Increment only the winning side, then leave both scores available for the next round.

Complete rock paper scissors code

The expression beats[player] equals computer means the player won. Reversing that comparison would award the point to the wrong side.

from random import choice

choices = ("rock", "paper", "scissors")
beats = {
    "rock": "scissors",
    "paper": "rock",
    "scissors": "paper",
}
player_score = 0
computer_score = 0

while True:
    player = input("rock, paper, scissors, or quit: ").strip().lower()

    if player == "quit":
        print(f"Final score: you {player_score}, computer {computer_score}.")
        break
    if player not in choices:
        print("Choose rock, paper, scissors, or quit.")
        continue

    computer = choice(choices)
    print(f"Computer chose {computer}.")

    if player == computer:
        print("Draw.")
    elif beats[player] == computer:
        player_score += 1
        print("You win this round.")
    else:
        computer_score += 1
        print("Computer wins this round.")

Follow one round through the state

I checked a rock-versus-scissors round and then quit.

The game awarded one point to the player and kept that score for the final line.

Computer chose scissors.
You win this round.
Final score: you 1, computer 0.

The player and computer scores live outside the loop because they belong to the whole match.

Put them inside the loop and every round starts from zero.

Tested Python rock paper scissors game showing computer choices, round results, and final score
Each round records the computer choice before updating the score.

For a first-to-five match, change the open-ended loop so it stops when either score reaches five. Keep quit as a separate exit because ending a match and abandoning one are different events.

3. Quiz game with question and answer pairs

A quiz is useful when you want to separate content from control flow.

The loop should not know which questions exist, only how to unpack a prompt and its accepted answer.

Why the data structure matters

Each tuple inside questions holds two related values.

The for loop unpacks them into question and answer, which lets you add another pair without changing the scoring logic.

  1. Start score at zero before the loop.
  2. Normalize the response with strip() and lower() so capitalization does not change correctness.
  3. Increase score only on an exact accepted answer.
  4. Use len(questions) for the denominator so the final total follows the data.

Complete quiz code

This version shows the accepted answer after a miss. That feedback turns a wrong response into information instead of a bare failure message.

questions = (
    ("Which keyword defines a function in Python? ", "def"),
    ("What file extension do Python source files use? ", "py"),
    ("Which function prints text in the terminal? ", "print"),
)

score = 0

for question, answer in questions:
    response = input(question).strip().lower()

    if response == answer:
        score += 1
        print("Correct.")
    else:
        print(f"Not quite. The answer is {answer}.")

print(f"Score: {score}/{len(questions)}.")

What I checked in the score path

I answered two questions correctly and one incorrectly.

The program showed the missing py answer and calculated 2/3 from the three stored pairs.

Correct.
Not quite. The answer is py.
Correct.
Score: 2/3.

The lowercase conversion works because every stored answer is also lowercase.

If you later accept names such as Guido van Rossum, normalize both sides or store a collection of accepted forms.

Tested Python quiz game showing correct feedback, incorrect feedback, and final score
An incorrect answer shows the expected value and the final score stays visible.

The next useful change is not a larger question list. Add an explanation beside each answer, then store prompt, answer, and explanation together so the loop can teach after either outcome.

4. Hangman with a set of guessed letters

Hangman introduces a harder state problem.

The program must remember every attempted letter, reveal repeated letters in the word, and count a miss only once.

How the hidden word becomes visible

The guessed set gives each letter one membership state.

A display expression walks through the word and shows the letter when it exists in guessed, otherwise it shows an underscore.

  1. Choose one word before the loop begins.
  2. Build the visible word from the current guessed set.
  3. Check for a solved word before requesting another letter.
  4. Reject malformed or repeated input before adding it to guessed.
  5. Increase misses only when a new letter is absent from the word.

Complete Hangman code

The else attached to while belongs to the loop, not the final if statement. It runs when misses reaches the limit, but it is skipped when break ends a solved game.

from random import choice

word = choice(("python", "function", "variable", "loop"))
guessed = set()
misses = 0
max_misses = 6
HANGMAN_STAGES = (
    "",
    " +---+\n |   |\n O   |\n     |\n     |\n=====",
    " +---+\n |   |\n O   |\n |   |\n     |\n=====",
    " +---+\n |   |\n O   |\n/|   |\n     |\n=====",
    " +---+\n |   |\n O   |\n/|\\  |\n     |\n=====",
    " +---+\n |   |\n O   |\n/|\\  |\n/    |\n=====",
    " +---+\n |   |\n O   |\n/|\\  |\n/ \\  |\n=====",
)

while misses < max_misses:
    visible = " ".join(letter if letter in guessed else "_" for letter in word)
    print(visible)

    if all(letter in guessed for letter in set(word)):
        print(f"You solved {word}.")
        break

    guess = input("Guess one letter: ").strip().lower()

    if len(guess) != 1 or not guess.isalpha():
        print("Enter one letter.")
        continue
    if guess in guessed:
        print("You already tried that letter.")
        continue

    guessed.add(guess)

    if guess not in word:
        misses += 1
        print(HANGMAN_STAGES[misses])
        print(f"Misses left: {max_misses - misses}.")
else:
    print(f"Out of guesses. The word was {word}.")

Why duplicate guesses need their own branch

I used loop as the target and entered o twice.

The second o produced the duplicate warning without consuming a miss, then p completed the word.

_ _ _ _
l _ _ _
l o o _
You already tried that letter.
l o o _
l o o p
You solved loop.

A set is a good fit for membership checks, but it does not explain whether a guess was correct or wrong.

If you want to show both histories, keep separate correct_guesses and wrong_guesses sets.

Tested Python Hangman game showing incorrect guesses, gallows progress, remaining misses, and a solved word
A missed letter adds one part to the gallows and reduces the remaining misses.

To extend the game, load words by difficulty and derive max_misses from the chosen mode. Keep word selection separate from the loop so difficulty changes setup rather than every turn.

5. Tic-tac-toe with a nine-item board

Tic-tac-toe teaches the difference between what a player sees and how a program stores it.

Players choose squares 1 through 9, while the list holding those squares uses indexes 0 through 8.

The board and winner model

The board starts with visible numbers so each open square tells the player which move to enter.

Once a move is accepted, that number is replaced by X or O.

  1. Convert the player’s square to a list index by subtracting one.
  2. Reject an occupied square before writing to the board.
  3. Check the eight possible winning lines after every accepted move.
  4. Check for a full board only after checking for a winner.
  5. Switch players only when the game continues.

Complete terminal tic-tac-toe code

The winner() function returns X, O, or None. Keeping that decision in a function makes it testable without running the input loop.

def board_text(board):
    rows = []
    for row in range(3):
        start = row * 3
        rows.append(" | ".join(board[start:start + 3]))
    return "\n---------\n".join(rows)


def winner(board):
    winning_lines = (
        (0, 1, 2), (3, 4, 5), (6, 7, 8),
        (0, 3, 6), (1, 4, 7), (2, 5, 8),
        (0, 4, 8), (2, 4, 6),
    )
    for a, b, c in winning_lines:
        if board[a] == board[b] == board[c]:
            return board[a]
    return None


board = [str(number) for number in range(1, 10)]
player = "X"

while True:
    print(board_text(board))
    move = input(f"{player}, choose a square (1-9): ").strip()

    if not move.isdigit() or not 1 <= int(move) <= 9:
        print("Choose a number from 1 to 9.")
        continue

    square = int(move) - 1
    if board[square] in ("X", "O"):
        print("That square is taken.")
        continue

    board[square] = player
    current_winner = winner(board)

    if current_winner:
        print(board_text(board))
        print(f"{current_winner} wins.")
        break
    if all(value in ("X", "O") for value in board):
        print(board_text(board))
        print("Draw.")
        break

    player = "O" if player == "X" else "X"

Trace invalid, occupied, and winning moves

I entered 10, then tried square 1 twice before completing the top row for X.

The invalid and occupied moves left the board unchanged, while accepted moves advanced the turn.

Choose a number from 1 to 9.
That square is taken.
X | X | X
---------
O | O | 6
---------
7 | 8 | 9
X wins.

The order of checks protects the board.

If the assignment happened before the occupied-square test, a player could overwrite the other mark.

Tested Python tic tac toe game showing an occupied-square rejection and an X winning row
The terminal game rejects an occupied square and ends on a winning row.

A strong next step is a replay loop that creates a new board for each match. Do not reset individual squares in several places because that makes stale state harder to spot.

Move from terminal loops to Tkinter callbacks

A terminal game owns the flow and pauses at input().

A Tkinter game gives control to the event loop, which calls your functions when the player clicks a button or presses Enter.

If widget-backed state is new to you, my Tkinter StringVar walkthrough explains how values move between Python and interface controls.

The games below use direct widget methods so you can see each state change where it happens.

6. Tkinter number guessing game

The graphical guessing game uses the same low, high, and correct branches as the terminal version. The difference is that check_guess() reads an Entry and writes feedback into a Label instead of returning to input().

What each callback owns

  • check_guess() validates one submitted value and updates the message.
  • new_game() chooses a new target, clears the Entry, resets the message, and returns keyboard focus.
  • The Return binding sends the same action as the Check button, so both input paths share one rule.

Complete Tkinter guessing code

The target is global because both callbacks need the same value.

In a larger application I would put this state on a class, but a global keeps this first callback example visible.

import tkinter as tk
from random import randint


def check_guess():
    raw = entry.get().strip()
    entry.delete(0, tk.END)

    if not raw.isdigit() or not 1 <= int(raw) <= 20:
        message.config(text="Enter a whole number from 1 to 20.")
    elif int(raw) < target:
        message.config(text="Too low.")
    elif int(raw) > target:
        message.config(text="Too high.")
    else:
        message.config(text="You got it. Start a new game to play again.")


def new_game():
    global target
    target = randint(1, 20)
    entry.delete(0, tk.END)
    message.config(text="New game. Enter a number from 1 to 20.")
    entry.focus()


app = tk.Tk()
app.title("Guess the Number")
app.resizable(False, False)

target = None
frame = tk.Frame(app, padx=24, pady=24)
frame.pack()

tk.Label(frame, text="Guess a number from 1 to 20", font=("TkDefaultFont", 16, "bold")).grid(row=0, column=0, columnspan=2, pady=(0, 12))
entry = tk.Entry(frame, width=14)
entry.grid(row=1, column=0, padx=(0, 8))
tk.Button(frame, text="Check", command=check_guess).grid(row=1, column=1)
message = tk.Label(frame, text="")
message.grid(row=2, column=0, columnspan=2, pady=(12, 0))
tk.Button(frame, text="New game", command=new_game).grid(row=3, column=0, columnspan=2, pady=(12, 0))
entry.focus()
app.bind("<Return>", lambda event: check_guess())
new_game()
app.mainloop()

Test the states, not only the window

I exercised invalid, low, high, correct, and reset paths.

The correction stayed inside the label, and new_game() replaced the target before the next round began.

The two screenshots show why the callback boundary matters. A low guess keeps the round active, while a correct guess leaves the result visible until the player starts again.

Tkinter number guessing game showing Too low feedback and a New game control
The window reports a low guess without closing the game.
Tkinter number guessing game showing a completed round and a New game control
A completed round stays visible until you start a new game.

Disable Check after a correct guess if you want a stricter finished state.

Re-enable it inside new_game() so the reset function remains the single place that prepares a round.

7. Tkinter tic-tac-toe

This version maps each button to one list index.

The interface and the board list must change together or the player can see one state while the winner check reads another.

How button clicks become board moves

  1. Capture each index in the button command when the grid is created.
  2. Reject clicks after the game ends or on an occupied square.
  3. Write the current player to the board and the matching button.
  4. Evaluate the board, then disable every button after a win or draw.
  5. Reset the list, player, finished flag, labels, and buttons together.

Complete Tkinter tic-tac-toe code

The lambda uses a default argument to capture the current index. Without i=index, every button would read the final loop value and try to play the same square.

import tkinter as tk


board = [""] * 9
player = "X"
finished = False
win_lines = ((0, 1, 2), (3, 4, 5), (6, 7, 8), (0, 3, 6), (1, 4, 7), (2, 5, 8), (0, 4, 8), (2, 4, 6))


def result():
    for a, b, c in win_lines:
        if board[a] and board[a] == board[b] == board[c]:
            return f"{board[a]} wins."
    if all(board):
        return "Draw."
    return ""


def play(index):
    global player, finished
    if finished:
        return
    if board[index]:
        status.config(text="That square is taken.")
        return

    board[index] = player
    buttons[index].config(text=player, state=tk.DISABLED)
    outcome = result()
    if outcome:
        finished = True
        status.config(text=outcome)
        for button in buttons:
            button.config(state=tk.DISABLED)
        return

    player = "O" if player == "X" else "X"
    status.config(text=f"{player}'s turn")


def play_again():
    global board, player, finished
    board = [""] * 9
    player = "X"
    finished = False
    status.config(text="X starts")
    for button in buttons:
        button.config(text="", state=tk.NORMAL)


app = tk.Tk()
app.title("Tic-Tac-Toe")
app.resizable(False, False)
frame = tk.Frame(app, padx=16, pady=16)
frame.pack()

status = tk.Label(frame, text="X starts", font=("TkDefaultFont", 13, "bold"))
status.grid(row=0, column=0, columnspan=3, pady=(0, 10))
buttons = []
for index in range(9):
    button = tk.Button(frame, width=6, font=("TkDefaultFont", 16, "bold"), command=lambda i=index: play(i), disabledforeground="#202020")
    button.grid(row=1 + index // 3, column=index % 3, padx=3, pady=3, ipadx=7, ipady=6)
    buttons.append(button)

tk.Button(frame, text="Play again", command=play_again).grid(row=4, column=0, columnspan=3, pady=(10, 0))
app.mainloop()

The reset path is part of the game

I played the top row to an X win and checked that further moves were blocked. play_again() then cleared all nine values, restored X, and enabled every button.

Tkinter tic tac toe game showing X wins and a Play again control
The Tkinter board shows the winning row and keeps a restart control visible.

Highlighting the winning three buttons is a useful next change.

Return the winning index tuple from result(), then use those indexes to change only the relevant button styles.

How to read and change these games

A complete program becomes teachable when you can point to its state, accepted actions, state transition, visible feedback, and stop condition. Use those five questions before adding graphics, sound, another player, or a larger word list.

  • What values describe the game right now?
  • Which inputs are accepted, rejected, or repeated?
  • Which line changes the state after a valid action?
  • How does the player see the result of that change?
  • What condition ends or resets the game?

Make one change at a time and predict which output should differ before you run it.

That prediction is what turns an edit into practice rather than guesswork.

Common mistakes that break beginner games

Most failures in these programs come from changing state too early or checking the end condition too late.

The fix is usually an ordering decision, not another library.

Counting rejected input as a turn

Validate first, then update guesses, scores, or board cells. A rejected action should explain the correction and return to the same game state.

Resetting only what the player can see

Clearing button text is not enough when a board list or finished flag still contains the previous round.

Reset the model and interface in one function.

Hiding rules inside repeated conditions

Store stable rules as data when the relationship is easier to inspect that way.

The beats dictionary and winning_lines tuple keep game rules out of the input loop.

Adding features before the stop condition works

A score, image, or sound cannot repair a loop that never ends or a board that accepts overwritten moves. Test win, loss, draw, invalid input, repeated input, and reset behavior first.

Where to go after these seven games

Use Turtle when drawing and motion are the next skill you want to practise.

Choose Pygame when you need sprites, sound, frame updates, and collision handling.

Do not discard the models you built here.

A Pygame loop still has state, accepted actions, transitions, feedback, and a stop condition, even though those decisions happen many times per second.

FAQ

These answers help you choose the right first project and the next technical step.

What is the easiest game to make in Python?

Number guessing is the smallest complete starting point. It teaches input validation, comparisons, a loop, visible feedback, and a clear stop condition.

Can I make Python games without Pygame?

Yes. The five terminal games use Python and its standard library, while the two windowed games use Tkinter. Pygame becomes useful when you need continuous animation, sound, sprites, or collision handling.

Should I type the game code or paste it?

Read the state and control flow before running the supplied version. After it works, change one rule and predict the new output so the edit tests your understanding.

Which game should I build after tic-tac-toe?

Build word scramble or Connect Four if you want more state and win-condition practice. Move to a small Turtle or Pygame project when you want drawing, motion, and frame updates.

Ninad
Ninad

A Python and PHP developer turned writer out of passion. Over the last 6+ years, he has written for brands including DigitalOcean, DreamHost, Hostinger, and many others. When not working, you'll find him tinkering with open-source projects, vibe coding, or on a mountain trail, completely disconnected from tech.

Articles: 136