Overview
In this tutorial, you will use Python to create your own version of Wordle, a popular word-guessing game.
Learning objectives:
- Use test-driven development (TDD) by writing tests before implementing the code needed to make those tests pass.
- Handle errors and unexpected situations using exceptions.
- Organize your code clearly using Model-View-Controller (MVC) pattern, with a clear separation between the game logic and the user interface.
- Configure your program using configuration files rather than hard-coding settings directly into the source code, making it easier to modify the game's behavior without changing the program itself.
- Develop different types of user interfaces, including a terminal-based interface and a web-based interface using Flask.
How to play Wordle
The objective of Wordle is to guess a hidden word within a limited number of attempts.
- The game selects a hidden word of a predetermined length, typically five letters.
- The player has a limited number of attempts to guess the word, typically six guesses.
- Each guess must contain the correct number of letters.
- After each guess, the game provides feedback for every letter:
- A letter is marked correct if it appears in the hidden word in the same position.
- A letter is marked present if it appears in the hidden word but is in a different position.
- A letter is marked absent if it does not appear in the hidden word.
5. The player wins by correctly guessing the hidden word before using all available attempts.
6. If the player uses all attempts without guessing the word, the game is over and the hidden word is revealed.
Importantly, the dictionary contains a limited set of valid words. If a user enters a word that is not present in the dictionary, the application informs them that the guess is invalid, but the attempt is not counted. The user can therefore enter another valid guess without losing an attempt.
The game may use different symbols, colors, or other visual indicators to display the feedback for each guess, depending on whether it is played in the terminal or through a web interface.
Project preparation
- Fork this project in GitLab.
- Clone the forked project into a folder of your computer.
- Open the project in Visual Studio Code (VSC).
- Create a virtual environment by typing the following command in the terminal integrated in VSC.
python3 -m venv .venv
- Activate the virtual environment:
source .venv/bin/activate(Linux/macOS),.venv\Scripts\activate(Windows PowerShell). - Update
pipwith the following command:
python3 -m pip install --upgrade pip
- Install the dependencies with the following command:
python3 -m pip install -r requirements.txt
Project structure
In addition to the Git-related files and the requirements.txt file that you are already familiar with, the project is organized into the following folders:
assets: This folder contains resources used by the application, such as the dictionaries from which secret words are selected and the configuration files used to customize the program. It can also contain other static resources, such as logos or images.src: This folder contains all of the project's source code. This is where you will complete the existing implementation and add new code files as needed. Some files have already been provided and will be explained as you progress through the assignment.tests: This folder contains the tests that your code must pass in order to be considered correct. Throughout the project, you will add and run tests to verify that your implementation behaves as expected.
The model-view-controller pattern
As the Wordle application grows, it is important to organize the code in a way that makes it easier to understand, test, and modify. To achieve this, the project follows the Model-View-Controller (MVC) design pattern.
MVC separates an application into three main components:
- Model: The model contains the core logic and data of the application. In this project, it is responsible for representing the state of the Wordle game and implementing its rules. For example, the model may store the secret word, keep track of the player's guesses, determine whether a guess is valid, and generate the feedback associated with each guess. The model should not depend on how the game is displayed to the user.
- View: The view is responsible for presenting information to the user and collecting user input. In this project, you will implement two different views, a terminal-based interface and, if time allows it, a web interface using Flask. The view should focus on how information is displayed rather than on the game logic itself.
- Controller: The controller acts as an intermediary between the model and the view. It receives input from the user through the view, requests the appropriate operations from the model, and updates the view with the results. In this way, the controller coordinates the interaction between the user interface and the game logic.
This separation of responsibilities provides several advantages. Since the game logic is independent of the user interface, the same model can be used with different views, such as a terminal application or a web application. It also makes the code easier to test, since the core game logic can be tested independently of the interface.
If you look inside the src folder, you will find three subfolders,
corresponding to the three components of the MVC pattern:
model, view, and controller.
Each of these subfolders may also contain an exceptions folder with
the code responsible for defining and
handling errors specific to the corresponding MVC component.
We start our implementation with the model.
The model
Unless the application is very easy, the code of the model should not appear in one single file. To have a better idea of which files we need, we need to think of what we need to implement the game rules.
What components are needed to implement the rules of the game?
As identified in the previous exercise, implementing the Wordle game requires several different components. This suggests that the game logic should be divided into multiple modules rather than implemented in a single file. We'll use object-oriented programming to implement these modules.
The dictionary
The dictionaries used by the game are provided as text files
in the assets folder.
We need to implement the code responsible for
loading and managing these dictionaries,
as well as the operations required by the game rules.
Before implementing the dictionary, it is useful to identify the operations that it must support and define the expected behavior of each one. This will allow us to design and test the dictionary component before writing its implementation.
Which operations must a dictionary support in order to implement the game rules?
Create a new branch
Since we are about to add a new piece of functionality to the project,
it is good practice to create a dedicated branch.
The following command will create a new branch called dictionary and switch to it
at the same time:
git switch -c dictionary
Create a new file named dictionary.py.
Where should this file be located within the project structure?
In this file:
- Define a
Dictionaryclass with a constructor that initializes a new, empty dictionary. When choosing the data structure for storing the words in memory, keep in mind the considerations discussed above regarding efficient word lookup. - Define the signature of each method corresponding to the operations identified above, including the method name and its parameters. At this stage, do not implement the methods; simply use the word
passas the method body.
You are free to choose appropriate and descriptive names for the methods.
Test-driven development
Rather than implementing the methods in class Dictionary immediately,
we will first define their expected behavior through tests.
For each operation, we will write one or more tests that describe the expected result.
We will then implement the corresponding functionality with
the goal of making all the tests pass.
Test-driven development
The key idea is to write the tests before writing the code that implements the required functionality. The tests therefore serve both as a specification of the expected behavior and as a way to verify that the implementation is correct. As the project develops, the tests should be run regularly. Whenever new functionality is added or existing code is modified, running the complete test suite helps ensure that the changes have not introduced errors or inadvertently broken functionality that was already working.
In this project, we will use pytest to automatically test the different
components of our application. pytest is a Python testing framework that discovers and runs test methods and reports whether they pass or fail.
To keep the tests organized,
we will group related tests into test classes.
Each class will correspond to a component or a specific group
of functionality that we want to test.
For example, all tests related to the dictionary
could be placed in a class called TestDictionary.
A test class is simply a Python class whose name starts with Test, and its test methods are functions whose names start with test_.
How to use pytest
To illustrate how pytest works, consider a simple program that provides a function for calculating the area of a rectangle:
def rectangle_area(width, height):
return width * height
We can create a test class to verify that this function behaves as expected:
class TestRectangleArea:
def test_area_of_rectangle(self):
assert rectangle_area(5, 3) == 15
def test_area_of_square(self):
assert rectangle_area(4, 4) == 16
The class TestRectangleArea groups together all the tests related to the rectangle_area() function. Each method whose name starts with test_ represents an individual test.
When pytest runs the tests, it will discover the TestRectangleArea class and execute both test methods.
The assert statement is at the heart of our tests. It allows us to specify the expected result of an operation.
For example:
assert rectangle_area(5, 3) == 15
This assertion states that we expect rectangle_area(5, 3) to return 15.
If it does, the assertion succeeds and the test passes.
If it returns a different value, the assertion fails and pytest reports the test as failed.
Assertions can also be used to check whether a condition is true:
assert 10 > 5
or false:
assert not 10 < 5
They can be used to compare values:
assert "hello".upper() == "HELLO" assert len([1, 2, 3]) == 3
They can also be used to check whether an element belongs to a collection:
assert "Python" in ["Python", "Java", "C++"]
In all these cases, the expression following assert must evaluate to True for the test to pass.
Create a new file named test_dictionary.py containing a class called TestDictionary.
Where should this file be located within the project structure?
The TestDictionary class will contain the tests for the Dictionary class that you created in the previous exercise. Since these tests need to create and use Dictionary objects, we first need to make the Dictionary class available in the test file.
To do this, add the following import statement at the beginning of test_dictionary.py:
from src.model.dictionary import Dictionary
Understanding imports
The import statement tells Python where to find the Dictionary class that we want to use.
The part
from src.model.dictionary
specifies the module from which we want to import the class. It corresponds to the path of the dictionary.py file within the project:
srcis the top-level folder containing the source code.modelis the folder containing the model classes.dictionaryrefers to thedictionary.pymodule. We do not include the.pyextension when importing a module.
The final part,
import Dictionary
specifies the name of the class we want to import from that module.
Once this import has been added, we can create instances of Dictionary in our tests.
The import therefore provides a connection between the test code in tests/model/test_dictionary.py and the Dictionary class implemented in src/model/dictionary.py.
We will now add tests to verify that words are correctly normalized when they are added to an empty dictionary. In other words, we want to write tests for the method of class Dictionary that adds a word to a dictionary.
Before writing the tests we need to consider three situations that may affect the way a word is stored:
- Leading and trailing whitespaces: Words loaded from a file may contain unintended spaces or other whitespace characters (such as tabulation characters) at the beginning or end. These should be removed before the word is stored.
- Accented characters: When playing Wordle in languages other than English, words may contain accented characters. Since players are typically not required to enter accents, these should be removed so that comparisons between guesses and the secret word remain consistent.
- Letter case: Players may enter words using uppercase letters, lowercase letters, or a mixture of both. To ensure a consistent representation, all words should be converted to lowercase before being stored.
In the TestDictionary class, add the following test methods (in all tests, we assume the dictionary to be empty).
- A test for adding a word with leading and trailing whitespace. The test should verify that the word is stored without the leading and trailing whitespace.
- A test for adding a word containing accented characters. The test should verify that the word is stored without accents.
- A test for adding a word containing letters with different cases. The test should verify that the word is stored entirely in lowercase.
You are free to choose the names of the test methods, but remember that every test method must begin with the prefix test_. This naming convention allows pytest to automatically discover and run the tests.
Run the tests with the following command:
python3 -m pytest tests/model/test_dictionary.py
As expected, the tests should fail, and this is exactly what we want at this stage. The method to add words has not been implemented yet, so there is no functionality for the test to verify.
Running tests with pytest
This section is given for information, but the notions presented here are not evaluated in the quiz at the end of this tutorial.
pytest provides several ways to select which tests to run.
When you simply execute pytest from the root directory of the project, pytest automatically discovers tests in the project. By default, it looks for files whose names follow the pattern test_*.py or *_test.py, and it looks for test functions and test methods whose names begin with test. It also discovers test classes whose names begin with Test.
For example, with the following project structure:
project/
├── src/
│ └── ...
└── tests/
└── model/
├── test_dictionary.py
└── test_game.py
running:
python3 -m pytest
will discover and run the tests in both files.
You can also use pytest to run a more specific set of tests. For example, to run all tests in a particular file:
python3 -m pytest tests/model/test_dictionary.py
To run all tests belonging to a particular test class:
python3 -m pytest tests/model/test_dictionary.py::TestDictionary
And to run a single test method:
python3 -m pytest python3 -m pytest tests/model/test_dictionary.py::TestDictionary::test_add_word
The :: notation allows you to navigate from the test file
to the test class and then to the specific test method.
This flexibility is particularly useful during development.
While working on a particular feature,
you can run only the relevant test or test class.
Once the feature is complete, you can run the entire test suite with python3 -m pytest
to make sure that your changes have not affected other parts of the project.
pytest also provides options for selecting tests based on other criteria.
For example, the -k option allows you to run tests whose names match a given expression:
python3 -m pytest -k add_word
This will run tests whose names contain add_word,
regardless of which test class or file they belong to.
You can also use the -v (verbose) option to obtain more detailed output:
python3 -m pytest -v
This displays the name and result of each test instead of only showing a compact sequence of dots.
As a general rule, use a specific test selection while developing a particular piece of functionality, and run the complete test suite regularly to ensure that the project as a whole continues to work correctly.
We can now implement the method responsible for adding a word to the dictionary. Before doing so, however, we need to ensure that all words are stored in a consistent format. Therefore we will first implement helper methods that convert a word to lowercase and remove its accents. Once these operations are defined, we can use them when implementing the method that adds a word to the dictionary.
About UNICODE normalization
When working with text in Python, two strings that look identical to a user may actually be represented differently internally.
For example, the character é can be represented either as a single Unicode character (U+00E9) or as two characters: e (U+0065) followed by a combining acute accent (U+0301).
Unicode normalization provides a way to convert different representations of the same text into a consistent form.
Two commonly used normalization forms are NFC (Normalization Form C) and NFD (Normalization Form D).
NFC generally combines characters and their combining marks into a single precomposed character when possible, while NFD decomposes characters into their basic characters and combining marks. For example, é is represented as a single character in NFC, whereas NFD represents it as e followed by a combining acute accent.
When comparing or processing words, normalization can therefore help ensure that visually identical strings are treated consistently, even when they originate from different Unicode representations.
In our Wordle implementation, we want all words stored in memory to use a consistent representation without accents. To achieve this, we can use Unicode normalization with the NFD form, remove the combining characters and keep only the base characters.
In Python, we obtain the NFD normalization with the following instructions:
import unicodedata
word = "café"
normalized = unicodedata.normalize("NFD", word)
We can remove the combining characters as follows:
normalized = ''.join(
char for char in unicodedata.normalize("NFD", word)
if not unicodedata.combining(char)
)
The resulting value is cafe.
Note that normalization and accent removal are two related but distinct operations: NFD decomposes the characters, while the additional filtering step removes the combining accent marks.
Add a private method _remove_accents() to the Dictionary class. The method should take a word as an argument and return the same word with all accents removed.
- Does the result of
_remove_accents()depend on the particularDictionaryinstance on which it is called? Your answer to this question should help you determine whether the method should be implemented as an instance method or a static method.
Still in class Dictionary, implement the method that adds a new word to a dictionary.
Before storing the word, ensure that the appropriate normalization is applied using the methods defined above.
Run the tests after the implementation. Correct the code if any of the tests fail.
In the previous exercises, we tested that a word can be correctly added to an empty dictionary.
We should now verify that the Dictionary class also behaves correctly when words are added
to a dictionary that already contains entries.
In particular, we need two tests:
- one to verify that a new word is correctly added to a non-empty dictionary;
- one to verify that a word that is already present is not added a second time.
Both tests require the same initial setup: a dictionary that has already been initialized and contains one or more words. Duplicating this setup code in every test would make the tests unnecessarily repetitive and harder to maintain.
Fortunately, pytest provides a mechanism called fixtures
that allows us to define reusable test setup code.
A fixture can create and initialize the objects or data required by one or more tests,
allowing each test to focus on the behavior it is intended to verify.
Add a method called test_dict() to the TestDictionary class.
The method should:
- Create a new
Dictionaryobject. - Add several words to the dictionary, using different letter cases and including both accented and unaccented words.
- Return the initialized dictionary.
👉 Decorate the method with @pytest.fixture()
to turn it into a pytest fixture.
Make sure to import the pytest module at the beginning of the test file.
Add two test methods to the TestDictionary class:
- one to verify that a word that is not already in the dictionary is correctly added;
- one to verify that a word that already exists in the dictionary is not added a second time.
Both test methods should take test_dict as an argument.
By including the fixture name as a parameter, pytest will automatically execute the test_dict() fixture and pass the resulting dictionary to the test method.
Run the tests to verify that all tests pass as expected.
Add to the TestDictionary class the tests for all the remaining methods defined in the Dictionary class.
- For each method, you may need to write more than one test to cover the different situations that can occur. This is the same approach we used when testing the method for adding a word to a dictionary. Think carefully about both the expected behavior and the possible edge cases.
- The function that tests loading a dictionary from a file should first create a temporary file containing a few words. Once the test is complete, the file should be removed using the
unlink()method provided by thepathlib.Pathmodule.
Once the tests have been written, your task is to implement each method in class Dictionary so that all the tests pass.
In this way, the tests act as both a specification of the required behaviour and a guide for the implementation.
Implement all the methods defined in the Dictionary class.
Once the implementation is complete, run the test suite and verify that all tests pass.
Commit
We have already done a lot of work, so it is time to commit our changes to Git.
First, check which files have been modified since the last commit:
git status
You should only see src/model/dictionary.py and tests/model/test_dictionary.py. If you have created other files, they may also appear in the output. However, you should only start tracking files that are relevant to your project.
If you only see the two files mentioned above, stage all changes with:
git add .
Otherwise, explicitly specify the files you want to stage:
git add src/model/dictionary.py tests/model/test_dictionary.py
Finally, commit your changes:
git commit -m "Dictionary implemented and tested"
Handling errors
In the previous code, we added to the class Dictionary a method that randomly selects a word with
the specified length.
However, we neglected the case where the dictionary does not contain any of the words of the
specified length.
We have here an error condition that we should carefully address using exceptions.
Exceptions
When a program runs, situations may occur that prevent an operation from completing normally. For example, a program may try to open a file that does not exist, convert an invalid string to a number, or access an element that is outside the bounds of a list. Python uses exceptions to signal and handle these exceptional situations.
An exception is an object that represents an error or an unusual condition that occurs during program execution. When an exception is raised, Python interrupts the normal flow of execution and looks for code that can handle it.
Predefined exceptions. Python provides many predefined exception types, such as ValueError, TypeError, FileNotFoundError, and IndexError. We can raise an exception explicitly using the raise statement:
age = -1
if age < 0:
raise ValueError("Age cannot be negative")
Using predefined exceptions is usually preferable when one of Python's existing exception types accurately describes the problem. For example, ValueError is appropriate when a function receives a value of the correct type but an invalid value.
Catching exceptions. Exceptions can be handled using a try/except statement:
try:
age = int(input("Enter your age: "))
except ValueError:
print("Please enter a valid number.")
If the user enters something that cannot be converted to an integer, int() raises a ValueError. The except block catches the exception and allows the program to respond appropriately instead of terminating unexpectedly.
It is important to catch only the exceptions that you actually know how to handle. For example, using:
try:
...
except Exception:
...
indiscriminately can hide programming errors and make debugging much more difficult. In general, it is better to catch a specific exception:
except ValueError:
...
Custom exceptions. Sometimes the predefined Python exceptions do not adequately describe an error specific to your application. In such cases, we can define a custom exception by creating a class that inherits from Exception (the notion of inheritance will be explained later in the course).
For example:
class InvalidWordError(Exception):
pass
We can then raise this exception when an invalid word is encountered:
if not word:
raise InvalidWordError("The word cannot be empty.")
And handle it in the same way as a predefined exception:
try:
process_word(word)
except InvalidWordError:
print("The word is not valid.")
Custom exceptions are particularly useful when the application has specific error conditions that are meaningful to its domain. They allow the code that uses a class or function to distinguish between different kinds of errors and respond appropriately.
Exceptions are not a replacement for validation. Exceptions should be used to report situations that prevent an operation from being completed normally. They should not be used unnecessarily for ordinary program flow.
For example, if a function expects a word to be present in a dictionary and its absence represents an invalid operation, raising an exception may be appropriate. On the other hand, if checking whether a word exists is itself a normal operation, a method such as:
dictionary.contains(word)
can simply return True or False.
The important distinction is between expected results and exceptional situations. A Boolean result is often appropriate when both outcomes are normal possibilities; an exception is more appropriate when an operation cannot proceed as requested.
If you look inside src/model/exceptions, you will find a file named NoWordOfLengthException.py. This file defines a custom exception class called NoWordOfLengthException. The class does not currently contain any additional code; it simply inherits from Python's built-in Exception class.
Add a method to the TestDictionary class to verify that the NoWordOfLengthException exception is correctly raised when attempting to randomly select a word whose length is not supported by the dictionary.
👉 In order to test that a function is expected to raise an exception, you can use the function pytest.raises() as in the following code:
with pytest.raises(ZeroDivisionError):
res = 3/0
Modify your implementation of the method to select a random
word from the dictionary by adding a raising of the exception
NoWordOfLengthException when the dictionary does not contain a word with the specified
length.
Run the tests and make sure that all pass.
Test coverage
Now that we have completed the implementation of our Dictionary class guided by the test-driven development principles, we may want to verify that our tests cover all the important cases.
To do so, we can run the following command:
python3 -m pytest --cov=src.model.dictionary
How code coverage is computed
Code coverage measures which parts of a program are executed when a test suite runs. In the simplest form, statement coverage is calculated as the percentage of executable statements that were executed by at least one test.
For example, suppose a class contains 20 executable statements and the tests execute 18 of them. The resulting coverage is 90%.
Coverage is a useful tool for identifying parts of the code that have not been tested, but it should not be interpreted as a measure of the quality of the tests. A test suite can achieve 100% statement coverage while still failing to test important cases or verify that the program produces the correct results. In this project, coverage should therefore be used as an additional tool to help identify missing tests, rather than as the sole measure of whether the implementation is correct.
Git merge
We have completed the implementation of the Dictionary class. It is time to merge our branch
dictionary into the main branch.
1. Commit the latest modifications in the branch dictionary using the same commands as above. Use a meaningful commit message (such as, "Added error handling in random selecting words with a length that does not match any word in dictionary").
2. Switch to the branch main using the following command:
git switch main
3. Merge the branch dictionary into branch main with the following command:
git merge dictionary
4. Delete the branch dictionary with the following command:
git branch -d dictionary
5. Push the latest commit to GitLab:
git push
👉 If you were working on this project with other developers using the same remote repository, you should run git pull between steps 2 and 3. This ensures that you merge your new functionality into the latest version of the project, including any changes made by your colleagues.
Game logic
Now that the dictionary component has been implemented, we can move on to the core of the application: the game rules.
You're adding a new functionality. Remember to work in a new Git branch.
To support a complete game of Wordle, the application must provide functionality that allows a user to:
- Create a new game. Each game has its own configuration, including the required length of the guesses, the maximum number of attempts allowed to guess the secret word, the secret and the dictionary from which the secret word is selected. Some of these settings may have default values.
- Submit a guess. The game must accept a player's guess and compare it with the secret word, producing the appropriate feedback for each letter.
- Determine whether the game has finished. The application must be able to determine whether the player has guessed the secret word and won the game, or whether the maximum number of attempts has been reached and the game has been lost.
In addition to implementing these operations, the game must keep track of its state throughout its execution. This includes the guesses submitted by the player and the result of evaluating each letter in every guess.
Although Wordle is a relatively simple game, it would not be advisable to place all of this functionality in a single class or file.
A better approach is to separate the different responsibilities into distinct components. For example, we can define one class to represent a guess and its letter-by-letter evaluation, and another class to represent the game itself and implement the game rules. This separation will make the code easier to understand, test, and maintain.
Implement the game rules, following the design suggestions presented above. Also,
- make sure to follow the principles of test-driven development: first write tests that describe the expected behaviour, and then implement the corresponding functionality until all tests pass.
- remember to handle error situations with exceptions. Add custom exceptions as necessary.
Ask your teacher to review and validate your design and implementation.
Remember to merge your branch into the main branch and push to GitLab.
The view
The view is responsible for the user interface. It allows the user to interact with the application by entering guesses and receiving feedback about the progress and outcome of the game.
Before implementing a graphical or web-based interface, we will first develop a simple terminal interface.
An important aspect of the MVC pattern is that the view should not interact directly with the model. Instead, the controller acts as an intermediary between the two components: it receives input from the view, invokes the appropriate operations on the model, and then instructs the view to display the resulting information.
The view will therefore provide the following operations:
ask_guess: Wait for the user to enter a new guess and return the value entered.show_invalid_length_guess: Display an error message when the user enters a guess whose length does not match the length required by the current game settings. Since the terminal allows the user to enter a word of any length, the application must provide feedback when the guess is too short or too long.show_invalid_guess: Take the guess entered by the user and display an error message indicating that it is not a valid word in the dictionary.visualize_guesses: Display all the guesses submitted by the user. Each guess includes feedback for its individual letters, indicating whether a letter is in the correct position, present in the secret word but in a different position, or absent from the secret word. This method may use a helper method to display a single annotated guess and call it for each guess submitted during the game.visualize_win: Display a message informing the user that they have won the game.visualize_loss: Display a message informing the user that they have lost the game.
In addition to implementing these operations, the view is responsible for decisions related to the presentation of information. This includes the style of messages and, in particular, the use of colors to represent the different states of the letters in a guess.
Although a terminal interface may appear relatively simple, Python provides libraries that make it possible to create more expressive and visually appealing terminal applications. In this project, we can use the rich library and its console API to display formatted and colored text, allowing us to provide clear visual feedback to the player.
You're adding a new functionality. Remember to work in a new Git branch.
How to use the rich.console API
In this project, we will use the Console class from the rich.console module to display formatted text, read user input, and create tables.
To begin, import and create a Console object:
from rich.console import Console console = Console()
The Console object provides methods with names that are familiar from Python's standard terminal interface. In particular, Rich provides its own versions of print() and input():
console.print("Hello!")
name = console.input("Enter your name: ")
The first statement displays text in the terminal, while the second displays a prompt, waits for the user to enter a value, and returns the value entered.
Displaying colored text. One way to style text with Rich is to use markup:
console.print("[red]This is an error message.[/red]")
console.print("[green]Operation completed successfully.[/green]")
console.print("[bold blue]Important information[/bold blue]")
The tags indicate the style that should be applied to the text between them. Rich supports many colors and formatting options, which can also be combined.
For example:
console.print("[bold]Welcome![/bold]")
console.print("[yellow]Warning: check your input.[/yellow]")
console.print("[red]An error occurred.[/red]")
Styling individual pieces of text with Text
For the Wordle interface, however, we will use a different approach. Rather than using markup strings, we will create Text objects and use the stylize() method to apply styles to specific parts of the text.
The Text class is provided by rich.text:
from rich.text import Text
A Text object represents text that can have different styles applied to different portions of it. For example:
text = Text("Hello world!")
text.stylize("bold blue", 0, 5)
console.print(text)
The arguments 0 and 5 specify the portion of the text to which the style should be applied. In this example, the characters from position 0 up to, but not including, position 5 are displayed in bold blue.
This allows different parts of the same Text object to have different styles:
text = Text("Success: operation completed")
text.stylize("bold green", 0, 7)
text.stylize("bold", 9, 26)
console.print(text)
This approach is particularly useful when displaying a sequence of tiles, where each letter needs to be displayed with a different style. For example, you could create a Text object for each tile and use stylize() to give the tile the appropriate foreground and background colors.
Creating tables. Rich also provides a Table class for displaying information in a structured format. To create a table, import it from rich.table:
from rich.table import Table
You can then create a table, define its columns, add rows, and display it using the console:
table = Table(title="Student Results")
table.add_column("Name")
table.add_column("Score")
table.add_column("Status")
table.add_row("Alice", "18", "Passed")
table.add_row("Bob", "12", "Passed")
table.add_row("Charlie", "7", "Failed")
console.print(table)
The table is built in several steps:
- Create a
Tableobject. - Add columns using
add_column(). - Add rows using
add_row(). - Display the table using
console.print().
The number of values passed to add_row() should correspond to the number of columns in the table.
For the Wordle interface, you can use the same ideas to construct the visual representation of the game. In particular, Text and stylize() should be used to create and style the individual tiles, while Console provides the basic mechanisms for displaying the resulting interface and interacting with the user.
Create a new file called console_view.py. Where should this file be located within the project structure?
- In this file, define a class called
ConsoleViewand implement the view operations discussed above. The class should also define attributes to store the colors and styles used for displaying messages and annotated guesses. Keeping these settings together will make it easier to modify the appearance of the interface without changing the implementation of each method. - When implementing the view, use the
Richlibrary to handle terminal input, output, and formatting. In particular, useConsolefor interacting with the terminal andTextobjects together withstylize()to format the individual tiles used to display annotated guesses. - You may consult the Rich Console API documentation for additional information about the available functionality.
- You may also get some help from an AI tool on this task.
After creating the view, remember to merge the branch in which you worked into the main branch and push to GitLab.
The controller
The controller coordinates the interaction between the view and the model. It receives input from the view, invokes the appropriate operations on the model, and passes the results back to the view for display. The view and the model remain independent of each other: neither needs to know about the other; only the controller interacts with both.
More specifically, the controller is responsible for the following tasks:
- Reading the game configuration. The controller reads the game settings from the configuration file
assets/wordle.ini, rather than hard-coding these values in the program. - Initializing the game. Using the settings read from the configuration file, the controller creates and initializes a new game.
- Running the game. During each iteration, the controller asks the view to obtain a guess from the user, submits the guess to the model, and retrieves the resulting annotation. It then passes this information to the view so that the guess and its feedback can be displayed.
- Finalizing the game. Once the game is over, the controller determines the outcome and instructs the view to display the appropriate message, informing the user whether they have won or lost.
You're adding a new functionality. Remember to work in a new Git branch.
The settings are given in a INI configuration file.
About INI
INI is generally understood to be short for initialization (as in an initialization file).
The .ini format originated with configuration files used by Microsoft Windows, particularly files such as WIN.INI and SYSTEM.INI.
A INI file allows us to keep values that control the behavior of a program outside of the source code. This is useful because settings can be changed without modifying the program itself.
INI files are organized into sections and key-value pairs. Consider the following configuration file:
[game] word_length = 5 max_attempts = 6 language = en dictionary = assets/dictionaries/wordle_en.txt
The section [game] groups together settings related to the game.
Within the section, each setting consists of a key and a value, separated by =:
A configuration file can contain multiple sections.
Create a new file called console_controller.py and define a class named ConsoleController. You should now be familiar with where this file belongs in the project structure.
Add a constructor to the ConsoleController class. The constructor should perform the following tasks:
- Read the configuration file and store the relevant settings in instance variables. These settings will be used later to initialize and run the game.
- Create a new dictionary using the
Dictionaryclass defined in the model.
Python's standard library includes the configparser module, which provides convenient functionality for reading INI configuration files. The documentation is available here.
Now we can focus on the flow of the game.
Add a run() method to the ConsoleController class.
This method should implement the overall flow of the game, coordinating the different components of the application. Among other tasks, the controller should:
- select a secret word from the dictionary;
- create and initialize the game using the settings loaded from the configuration file;
- create the view;
- repeatedly obtain guesses from the view and submit them to the model;
- pass the results produced by the model back to the view;
- detect when the game is over and display the appropriate outcome.
The controller should use the methods provided by both the model and the view. Remember to import the classes you need before using them.
It is now time to create the main entry point of the application: the script that starts the game.
Create a file called wordle_console_app.py and place it in the src folder. This file should contain the code needed to start the application. Its main responsibility is to:
- create an instance of ConsoleController;
- invoke its
run()method.
Keeping the entry point this simple is intentional. The controller is responsible for coordinating the application, while wordle_console_app.py simply starts the process.
👉 Time to play. Execute the application with the following command:
python3 -m src.wordle_console_app
If everything works correctly, merge the latest modifications into the main branch and delete
the other branches. Also remember to push the latest commit on the main branch to GitLab.
This ends the tutorial. If you still have time, you can learn how to create a Web interface with Flask
Advanced: Graphical interface with Flask
Flask is a lightweight Python web framework that allows us to create web applications. It provides the functionality needed to receive requests from a web browser, execute Python code in response, and send a response back to the browser.
As you may recall from the course on networks, the browser and the web server communicate using the HTTP protocol. HTTP provides a certain number of methods, but two are particularly important in the context of this project:
- GET is generally used to request information from the server, such as displaying a page.
- POST is generally used to submit information to the server, such as a user's guess.
Browser Flask application | | | GET / | |--------------------------------->| | | | HTML page | |<---------------------------------| | | | POST /guess | | guess = "example" | |--------------------------------->| | | | Updated page | |<---------------------------------|
The communication between a browser and a Flask application is slightly more complex than it may initially appear. When a user interacts with a web page—for example, by clicking a link or submitting a form—the browser sends an HTTP request to a web server. The web server is responsible for receiving the request and determining how it should be handled.
However, a traditional web server does not know how to directly execute a Python web application. To solve this problem, the Python community developed WSGI (Web Server Gateway Interface), a standardized interface that defines how a web server communicates with a Python web application. WSGI is specified in PEP 333.
The basic architecture is therefore:
Browser
│ │ HTTP request ▼
Web server
│ │ WSGI ▼
Python web application
│ ▼
HTTP response
│ ▼
Browser
A WSGI server, such as Gunicorn or Waitress, is responsible for running the Python application and communicating with it through the WSGI interface. These servers can accept HTTP requests themselves, but in a production environment they are often placed behind a separate web server. The web server can handle tasks such as serving static files, managing HTTPS connections, and filtering or forwarding incoming requests, while the WSGI server focuses on running the Python application. The web server placed in front of the application server is called a reverse proxy. A typical production architecture therefore looks like this:
Browser
│ │ HTTP/HTTPS ▼
Web server / Reverse proxy
│ │ WSGI ▼
WSGI server
│ ▼
Flask application
First Flask application
To become familiar with Flask and its terminology, let's start with a simple example. We will create a small Flask application and use it to explore some of the concepts introduced in the previous sections.
Inside the src/playground folder, create a new file called flask_sample_app.py. We will gradually build our example application in this file.
App initialization
A Flask application is represented by a Python object created from the Flask class. Add the following code to flask_sample_app.py:
from flask import Flask app = Flask(__name__)
The app object represents our web application. It is responsible for receiving HTTP requests and determining how they should be handled.
The argument passed to the Flask constructor is the name of the application's module or package. In most cases, using __name__ is the appropriate choice. __name__ is a special Python variable containing the name of the module in which the code is defined.
In our example, its value is src.playground.flask_sample_app.
Flask uses this information to determine where the application is located and where it should look for resources associated with it, such as templates and static files, which we will introduce later.
Routes and views
When users interact with a web application, they request specific URLs (Uniform Resource Locator), such as http://example.com/wordle. The application must determine what to do when a particular URL is requested.
Flask introduces the concept of a route for this purpose. A route associates a URL with a Python function that should be executed when that URL is requested. The function is called a view.
For example, the following code defines a route for /, which conventionally represents the home page of a web application:
@app.route("/")
def home():
return "Hello, world!"
Here, / is the route, while home() is the view function associated with that route. When a browser requests /, Flask calls home() and sends the returned value back to the browser.
👉 Add this code to flask_sample_app.py.
Running the application
We can now start the application from the terminal with the following command:
flask --app src.playground.flask_sample_app run --debug
The command should produce some output in the terminal and then appear to stop responding. This is expected: Flask has started the development server and is now waiting for incoming requests.
Among the messages displayed in the terminal, you should see something similar to:
Running on http://127.0.0.1:5000
Open this address in a web browser. You should see:
Hello, world!
Port 5000 already in use?
If you see:
Address already in use Port 5000 is in use by another program.
it means that another application is already using port 5000. You can either stop that application or tell Flask to use a different port. For example:
flask --app src.playground.flask_sample_app run --debug --port 5001
You would then access the application at http://127.0.0.1:5001.
What happens when you run a Flask application?
When you run the application using the flask command, Flask starts its built-in development server, which provides a WSGI interface for the application. This server is convenient for development and testing, but it is not designed for production use.
It does not provide the level of performance, robustness, and security required to handle real-world production traffic. This is why Flask displays a warning reminding you not to use the development server in production.
The --debug option enables debug mode. This provides two particularly useful features during development.
First, if an error occurs while processing a request, Flask displays a detailed traceback in the browser. This makes it easier to identify where and why the error occurred.
Second, Flask automatically reloads the application when you modify the source code. You can therefore edit your code, save the file, and test the changes immediately without manually stopping and restarting the server.
You may also notice a message in the terminal containing a Debugger PIN, such as:
Debugger PIN: 123-456-789
The PIN belongs to Flask's interactive debugger. In addition to displaying the traceback, the debugger can provide an interactive environment in the browser where a developer can inspect the application's state and, when appropriately authenticated, execute Python expressions.
This capability is extremely powerful and potentially dangerous if it were accessible to anyone who could connect to the application. The PIN therefore provides an additional layer of protection for the interactive debugger.
For this reason, debug mode should only be used during development and testing. In particular, a Flask application with the interactive debugger enabled should never be exposed to untrusted users or deployed as a production application.
👉 Add the following code to file flask_sample_app.py :
@app.route("/about")
def about():
return "About page"
@app.route("/hello/<name>")
def hello(name):
return f"Hello, {name}!"
@app.route("/status")
def status():
return "<h1>Everything is working!</h1>"
As you can see in the second route, routes can also have dynamic parts. In the third route, the view return some HTML code, the code used to create a web page.
👉 Play with the application to make sure that all routes function correctly.
Templates
In a real web application, we generally do not want to write complete HTML pages directly inside Python functions. Doing so would mix the application's presentation with its Python code, making the application harder to organize and maintain.
To address this issue, Flask supports templates. A template is typically an HTML file that defines the structure of a web page while including placeholders for information that will be provided dynamically by the Python application.
Flask uses the Jinja template engine to process templates. For example, consider the following template:
<!DOCTYPE html>
<html>
<body>
<h1>Hello, {{ name }}!</h1>
</body>
</html>
The expression {{ name }} is a Jinja placeholder. When the template is rendered, Flask replaces it with the value associated with the variable name.
By default, Flask looks for templates in a folder named templates. This folder is normally located relative to the module or package in which the Flask application is defined. The location can also be customized using the template_folder argument when creating the Flask application.
👉 Create a folder named templates inside src/playground, and create a file called hello.html containing the template shown above. Then modify your Flask application so that the /hello/<name> route renders the template:
from flask import render_template
@app.route("/hello/<name>")
def hello(name):
return render_template("hello.html", name=name)
The call to render_template() performs two tasks:
- It loads the template file
hello.htmlfrom the templates folder. - It passes the value of the
namevariable to the template.
For example, if the user visits /hello/Alice, Flask calls hello("Alice")
The function then renders hello.html, passing Alice as the value of name. Jinja replaces {{ name }} with that value, and the browser receives the resulting HTML:
<h1>Hello, Alice!</h1>
In this way, the Python code remains responsible for handling the request and preparing the data, while the template is responsible for defining how that data is presented in the browser.
Wordle interface
For our Wordle game, we would like to create a web interface that provides the main elements needed to play and configure the game:
- The game board. The board consists of a grid of tiles. The number of columns corresponds to the number of letters in a valid guess, while the number of rows corresponds to the maximum number of attempts. Each tile represents a single letter entered by the player.
- A virtual keyboard. The keyboard is displayed below the game board and provides an overview of the letters used throughout the game. As guesses are submitted, the keys can be colored to indicate the status of each letter.
- Game settings. The interface should allow the user to start a new game and modify settings such as the language, the required length of a guess, and the maximum number of attempts.
All the code related to the web interface will be located in the src/view/web folder. This folder already contains two important subfolders:
templates: This folder contains the HTML templates used to define the structure and content of the web pages. As HTML is not part of the SIP program, the templates are provided and you will not need to modify them.static: This folder contains static files, that is, files that are sent to the browser without being dynamically generated by the application. It includes thestyle.cssfile, which contains the styling instructions for the web interface. As with the HTML templates, the CSS file is provided as part of the project, since CSS is not covered by the SIP program.
CSS
The style.css file uses Cascading Style Sheets (CSS), the language commonly used on the web to control the appearance and layout of HTML elements. CSS allows us to separate the content and structure of a page, defined in HTML, from its presentation, such as colors, fonts, spacing, and positioning.
Game Configuration
The game configuration is stored in the wordle.toml file, located in the assets folder.
We use the TOML format because it provides a more structured way of representing configuration data than the traditional INI format. TOML supports features such as nested tables, arrays, and explicit data types, which are useful when configuration becomes more complex.
For example, the configuration can organize language-specific settings hierarchically:
[languages.en] label = "English" flag = "🇬🇧" dictionary = "assets/dictionaries/wordle_en.txt" [languages.fr] label = "Français" flag = "🇫🇷" dictionary = "assets/dictionaries/wordle_fr.txt"
Here, en and fr are sections nested within the languages section. In contrast, an INI parser would generally interpret [languages.en] and [languages.fr] as two separate section names rather than as nested sections.
Application Factory
As we have seen, a Flask application is an instance of the Flask class. Routes, views, configuration settings, and other application components are registered with this instance.
In our first example, we placed all of this code in a single file. This approach is convenient for a small example, but it quickly becomes difficult to manage as an application grows. In a larger project, we therefore want to split the code into several modules.
This raises an important question: where should the Flask application object be created?
One possibility would be to create a global Flask object that can be imported by the different modules of the application. However, the Flask documentation recommends avoiding this approach in larger applications, as it can lead to problems related to imports, testing, and the management of multiple application instances.
Instead, Flask recommends using an application factory. An application factory is simply a function that creates and configures a new Flask application. All the instructions needed to initialize the application, such as loading the configuration and registering routes and other components, can be placed inside this function.
The application factory is conventionally defined in a file called __init__.py. In our project, this file will be located in src/view/web/__init__.py
Remember to work in another Git branch now.
Create a file called __init__.py inside the src/view/web folder and add the following code:
from pathlib import Path
import tomllib
from flask import Flask
from src.view.web.wordle_state import initialize_game
def create_app():
app = Flask(__name__)
with open("assets/wordle.toml", "rb") as f:
config = tomllib.load(f)
app.config.from_mapping(
GAME=config["game"],
SETTINGS=config["settings"],
LANGUAGES=config["languages"]
)
return app
The create_app() function is our application factory. When we later start the application using the flask command, Flask will locate and invoke this function to create the application.
The function performs the following steps:
- Creates a Flask application by instantiating the
Flaskclass. - Loads the configuration from
assets/wordle.tomlusing Python'stomllibmodule, which is part of the standard library. - Registers the configuration with the Flask application using
app.config.from_mapping(). - Returns the configured application.
The configuration is stored in app.config, Flask's configuration object. We store the three main sections of our TOML configuration under the keys GAME, SETTINGS, and LANGUAGES.
Notice that these keys are written in uppercase, following Flask's convention for application configuration keys:
app.config["GAME"] app.config["SETTINGS"] app.config["LANGUAGES"]
The values associated with these keys are the corresponding sections loaded from the TOML file.
At this stage, the factory only creates the application and loads its configuration. As we continue developing the web interface, we will add the remaining initialization steps, such as registering routes.
Initialization of the game
If we run the application at this point, Flask will start successfully, but requesting a URL will result in a 404 Not Found response. This is because we have not yet defined any routes or view functions.
Before defining the routes, however, we need to implement some functions that will manage the state of the Wordle game for the web application. We will place these functions in a new Python module called wordle_state.py, located in src/view/web/.
Create the file wordle_state.py in folder src/view/web/ and define a function called initialize_game().
The function should take two arguments:
game_settings: a dictionary containing the settings from the[game]section ofwordle.toml.language_settings: a dictionary containing the settings from the[languages]section ofwordle.toml.
The function should use these settings to create and initialize a new game, and return an instance of the Game class (defined in src/model/game.py).
👉 In __init__.py, add the following line inside the create_app() function, before return app:
app.extensions["wordle"] = initialize_game(
config["game"],
config["languages"],
)
Why store the game instance in app.extensions?
Flask's app.extensions dictionary is intended for storing application-specific extensions and objects that need to be accessible throughout the application.
In our case, we want to create the Game instance once, when the Flask application is initialized, and then make it available to the different parts of the application that need it.
By storing it as app.extensions["wordle"] we can retrieve the same Game instance later from the Flask application instead of creating a new game each time a request is handled.
This is particularly useful here because the Game object represents the state of the Wordle game. If we created a new instance for every request, information such as the guesses already submitted by the user would be lost.
The wordle key serves as a name under which our application can access this shared game instance.
Board view
We will now create the board view.
Add a function build_board_view() to wordle_state.py.
The function takes two arguments:
game: an instance of theGameclass, i.e., the object returned byinitialize_game().settings: a dictionary containing the settings defined in the[settings]section ofwordle.toml.
The function must return the board view as a list of lists of dictionaries.
More precisely:
- Each row of the board represents a user guess and is represented as a list of dictionaries.
- Each dictionary represents a single cell and contains two keys:
- "letter": the letter entered by the user.
- "state": the state of the letter, which must be one of "correct", "present", or "absent".
Some, or all, of the rows in the board may still be empty if the user has not used all of their attempts. For these rows, each cell should contain:
{"letter": "", "state": "unknown"}
Thus, every row in the returned board should have the same number of cells, regardless of whether the row contains a guess.
Keyboard state
We now implement a function for handling of the virtual keyboard state.
Implement the function build_keyboard_state() in wordle_state.py.
The function builds the state of the game's virtual keyboard based on the guesses submitted by the user.
The function takes one argument:
game: an instance of theGameclass. You can use the methods provided by this object to retrieve the guesses submitted by the user.
The function must return a dictionary mapping each letter of the alphabet to its current state:
{
"A": "unknown",
"B": "absent",
...
"Z": "unknown",
}
The returned dictionary must contain all letters from A to Z, initially with the state "unknown". Use the guesses submitted by the user to determine the state of each letter. Each guess contains letters together with their corresponding states. The possible states of a letter are:
- "unknown": the letter has not appeared in any submitted guess.
- "absent": the letter has been guessed but does not occur in the target word.
- "present": the letter occurs in the target word but is in the wrong position.
- "correct": the letter occurs in the correct position.
A letter may occur in multiple guesses with different states. In this case, keep the highest-priority state. The priority, from lowest to highest, is:
unknown < absent < present < correct
For example, if A is "absent" in one guess and "present" in another, its final state should be "present". If it is "correct" in any guess, its final state should be "correct".
The keyboard uses uppercase letters, so make sure that letters obtained from guesses are converted to uppercase before updating the dictionary. The function should return the completed dictionary after processing all submitted guesses.
Game status
We now implement a function to keep track of the game status.
Implement the function build_status() in wordle_state.py.
The function builds a dictionary describing the current status of the game, based on the state of the Game object.
The function takes one argument:
game: an instance of theGameclass.
The function should return a dictionary with two keys:
- "type": indicates the type of status.
- "message": contains the message that should be displayed to the user.
The game can be in one of three states:
- The player has won. In this case, return:
{"type": "win", "message": "Great job! You guessed the word."} - The game is finished without a win. If the player has used all available attempts without guessing the word, return a status of type "loss". The message should include the secret word in uppercase.
- The game is still in progress. If the player has neither won nor run out of attempts, return:
{"type": "info", "message": ""}
Important. Check the game states in the appropriate order. In particular, a game that has been won should be reported as a win, even if the winning guess also causes the game to be considered finished.
Routes and views
We now define the routes and implement the corresponding views to display the game, submit guesses, start a new game, and modify the game settings.
Rather than defining all routes and views in a single file, we group related functionality using a Flask concept known as a blueprint. In this project, we can define two separate blueprints:
game, which contains all the routes and views related to the game itself.settings, which contains all the routes and views related to configuring the game.
Create a new file game.py in folder src/view/web and write the following
code:
from flask import Blueprint
bp = Blueprint('game', __name__)
This creates a new blueprint called game.
Once the blueprint has been defined, we can start associating routes with it. We begin with the route for the index page, /.
We will then incrementally build the corresponding view to render the index page and display the game interface.
Define an index() view in the file game.py and associate it with the / route. Since the route belongs to the blueprint, use the bp.get("/") decorator.
The index() function must:
- Retrieve the game settings from
current_app.config["GAME"]. To accesscurrent_app, import it fromflask. Recall that we used an application factory to create and initialize the Flask application. Outside the application factory, the current application can therefore be accessed throughcurrent_app. Store the game settings in a variable namedgame_settings. - Retrieve the current
Gameinstance. In the application factory, we stored an instance of theGameclass inapp.extensions["wordle"]. Theindex()function should therefore retrieve it fromcurrent_app.extensions["wordle"]. Store the game instance in a variable namedgame.
In order to show the game interface, the function index() must build the board view and the keyboard state. These are components that are needed by the template index.html. Let's define them.
Add the following instructions to the function index():
- Retrieve the board view using the function
build_board_view()that you defined in filewordle_state.py. Store the board view in a variable namedboard_view. - Retrieve the keyboard state using the function
build_keyboard_state()that you defined in filewordle_state.py. Store the keyboard state in a variable namedkeyboard_states. - Retrieve the current game status using the function
build_status()that you defined in filewordle_state.py. Store the game status in a variable namedstatus. - Determine the index of the active row (the row in the board view where the user is expected to type the next guess). If the game is still in progress, the active row corresponds to the number of guesses already submitted; if the game is finished, there is no active row, so use -1. Store the active row in a variable named
active_row.
It is now time to render the index.html template. This template requires several arguments, some of which correspond to values that we retrieved in the two preceding exercises.
Add the following instruction to function index():
return render_template(
"index.html",
game_settings=game_settings,
general_settings=current_app.config["SETTINGS"],
language_settings=current_app.config["LANGUAGES"],
board_rows=board_view,
keyboard_states=keyboard_states,
active_row=active_row,
error_message=request.args.get("error", ""),
status=status,
)
Please note that:
- You need to import
render_templateandrequestfromflask. - We pass the general and language settings to the template.
- We pass an
error_messageto the template. This message is retrieved from the optionalerrorquery parameter, which can be provided when requesting the/page, for example:/?error=Invalid+word. We will use this mechanism later to display error messages to the user during the game.
The blueprint must now be registered with the Flask application. Otherwise, the routes associated with the blueprint will not be available to the application.
Add the following instructions to the function create_app() in file __init__.py right before the instruction return app:
from . import game app.register_blueprint(game.bp)
It is now time to test the application.
👉 Type the following command:
flask --app src.view.web run --debug
If you enter the URL http://127.0.0.1:5000/ in your browser, you should see the game board and the virtual keyboard. You should also be able to enter a guess in the first row. However, you will not yet be able to submit the guess, as we have not implemented the route responsible for handling it.
👉 Now try entering the URL http://127.0.0.1:5000/?error=test. You should see the message "test" highlighted in red in the interface. This demonstrates the purpose of the error_message argument that we added to the render_template() call.
Let's now implement the view associated with the /guess route. This will allow the user to submit a guess and receive feedback.
Add a submit_guess() function to game.py. This function will retrieve the guess entered by the user and submit it to the current Game instance, using the method you implemented when developing the game model.
- Decorate the function with
@bp.post("/guess"). We use thePOSTmethod because the user is submitting data to the server. - Retrieve the submitted guess with:
request.form.get("guess", "").strip().lower(). If you look atindex.html, you will find an HTML form element namedguessthat is used to enter the user's guess. The instruction above retrieves the value submitted through this element. The call tostrip()removes leading and trailing whitespace, whilelower()converts the guess to lowercase. - The function should end with:
return redirect(url_for("game.index")). Bothredirectandurl_formust be imported fromflask. This instruction redirects the user to the index page, which is associated with the/route. Notice that we usegame.indexrather than simplyindex. This is because theindexview belongs to thegameblueprint.- We also use
url_forinstead of hard-coding the URL/. This way, if we later change the route associated withindex(), we do not need to modify the code that redirects to it. - The
redirectcauses the browser to make a new request to the index page. Theindex()view then renders the current state of the game, including the newly submitted guess.
- We also use
- Make sure to catch all exceptions that may be raised when submitting the guess to the
Gameinstance. For example, if the user submits a word that is not in the dictionary, an appropriate error message should be displayed. You can redirect to the index page while passing the error message as a query parameter with the following instruction:
return redirect(url_for("game.index", error="The word is not in the dictionary"))
The error parameter will then be available to the index() view and can be passed to the index.html template for display to the user.
👉 In template index.html uncomment lines 86-88 by removing {# at line 86, and #} at line 88. Run the app and play the game!
Is everything working as expected? You should now notice, however, that there is no option to start a new game. Let's add this functionality!
Add a new view new_game() to game.py and associate it with the /new-game route. The view should initialize a new game.
Uncomment lines 52–54 in the index.html template. This will display a button that allows the user to start a new game.
Run the application and test the new functionality. Make sure that clicking the button starts a new game as expected.
Game settings
The only remaining functionality is to allow users to modify the game settings: the language, the number of attempts, and the length of the guesses.
In the index.html template, the code responsible for displaying the interface elements that allow users to change these settings is located on lines 13–50.
👉 Uncomment this code to enable the settings interface.
There are three types of settings:
- "language", whose value is the code of the currently selected language.
- "word_length", whose value is the length of the guesses.
- "max_attempts", whose value is the maximum number of attempts allowed.
Add a new file settings.py to folder src/view/web.
In this file:
- Define a new blueprint named
settings. Remember to register this blueprint with the application by adding the relative code in file__init__.py. - Add a view
update_setting()associated with route/settings. This function must:
- Get the name of the setting that changes via
request.form.get("setting"). - Get the value of the setting via
request.form.get("value"). - Change the selected setting in the app, modifying the appropriate setting in
current_app.config["GAME"]. - Initialize a new game with the new settings and redirect to the index page.
👉 Use the application and make sure you can change the settings correctly.
Time to merge your branch into main and push to GitLab.
Conclusions
We have reached the end of this tutorial. Throughout the project, you have progressively built a complete application while applying several important software development principles and techniques.
Here are some of the key concepts you should remember:
- Test-driven development (TDD). You learned how tests can guide the development process by defining the expected behaviour of your code before, or alongside, its implementation.
- The Model-View-Controller (MVC) pattern. You learned how to separate the different responsibilities of an application by distinguishing between the model, which manages the application's data and logic, and the views and controllers, which handle user interaction and presentation.
- Modular software design. You learned the importance of organizing an application into separate modules and components, each with a clear responsibility. This makes the code easier to understand, test, maintain, and extend.
- Exception handling. You learned how to identify error conditions and use exceptions to handle them appropriately, rather than mixing error-handling logic with the normal flow of your application.
You also learned how to:
- Write automated tests using
pytest, including tests that verify both the expected behaviour of your code and the correct handling of exceptional situations. - Build a terminal-based user interface, allowing users to interact with the game directly from the command line.
- Build a web-based user interface using
Flask, including routes, views, templates, forms, redirects, and blueprints. - Organize a Flask application using an application factory and modular blueprints.
- Use Git to track your changes and progressively develop the project.
If you still have time, you may try to add the functionality to play Dordle.

