...with properly packaged code, automated testing, and *no dependencies*. Only Python 3.12 is supported.

Please ignore my non-PEP8-compliant code style. Only pyproject.toml and .py files are included here; still, there are about 1400 LOC. For non-code files (and/or a better code-reading experience), view the project [on GitHub][1].

## The project structure

```none
.
├── LICENSE
├── README.md
├── pyproject.toml
├── src
│   ├── _hangman
│   │   └── runner.py
│   └── hangman
│       ├── __init__.py
│       ├── _lax_enum.py
│       ├── _static_reader.py
│       ├── assets
│       │   ├── gallows.txt
│       │   ├── head.txt
│       │   ├── instructions.txt
│       │   ├── left_arm.txt
│       │   ├── left_leg.txt
│       │   ├── right_arm.txt
│       │   ├── right_leg.txt
│       │   ├── title.txt
│       │   ├── trunk.txt
│       │   └── you_lost.txt
│       ├── canvas.py
│       ├── choice_list.py
│       ├── conversation.py
│       ├── game.py
│       ├── py.typed
│       ├── word.py
│       ├── word_list.py
│       └── words
│           ├── easy.txt
│           ├── hard.txt
│           ├── medium.txt
│           └── unix.txt
├── tests
└── tox.ini
```

## pyproject.toml

```lang-toml
[project]
name = "hangman"
version = "0.2.1"
description = "A CLI hangman game"
readme = "README.md"
requires-python = ">=3.12"
license = { text = "Unlicense" }
authors = [
	{ name = "InSyncWithFoo", email = "[email protected]" }
]
classifiers = [
	"License :: OSI Approved :: The Unlicense (Unlicense)",
	"Topic :: Games/Entertainment"
]

[project.optional-dependencies]
dev = [
	"build~=1.0.3",
	"mypy~=1.6.0",
	"pytest~=7.4.2",
	"pytest-cov~=4.1.0",
	"setuptools~=68.2.2",
	"tox~=4.11.3"
]

[project.urls]
Homepage = "https://github.com/InSyncWithFoo/hangman"

[project.scripts]
hangman = "_hangman.runner:main"

[build-system]
requires = ["setuptools>=68.0.0", "wheel"]
build-backend = "setuptools.build_meta"

[tool.setuptools]
package-data = { "*" = ["*.txt"] }
```

## src/_hangman/runner.py

```py
from hangman import Game


def main():
	Game().start()


if __name__ == '__main__':
	main()
```

## src/hangman/__init__.py

```py
from .canvas import Canvas, Layer
from .choice_list import ChoiceList, Choices
from .conversation import Conversation
from .game import Game
from .word_list import Level, WordList


__all__ = [
	'Game',
	'Canvas',
	'Choices',
	'ChoiceList',
	'Conversation',
	'Layer',
	'Level',
	'WordList'
]
```

## src/hangman/_lax_enum.py

```py
import re
from collections.abc import Generator
from re import Pattern
from typing import ClassVar


class LaxEnum(type):
	'''
	Despite its name, a LaxEnum is no different from
	a normal class except for that it yields every
	item that is not a dunder when being iterated over.
	'''
	
	_dunder: ClassVar[Pattern[str]] = re.compile(r'__.+__')
	
	def __iter__(cls) -> Generator[object, None, None]:
		for member_name, member in cls.__dict__.items():
			if not cls._dunder.fullmatch(member_name):
				yield member
```

## src/hangman/_static_reader.py

```py
from functools import partial
from os import PathLike
from pathlib import Path


package_directory = Path(__file__).resolve().parent
assets_directory = package_directory / 'assets'
word_list_directory = package_directory / 'words'


def _read(base_directory: Path, filename: PathLike[str]) -> str:
	'''
	Read a file and return its contents.
	
	:param base_directory: The base directory to look up the file
	:param filename: The name of the file
	:return: The contents of the file
	'''
	
	with open(base_directory / filename) as file:
		return file.read()


get_asset = partial(_read, assets_directory)
get_word_list = partial(_read, word_list_directory)
```

## src/hangman/canvas.py

```py
from __future__ import annotations

import dataclasses
from collections.abc import Collection, Generator, Iterator, Sequence
from dataclasses import dataclass
from functools import partial
from itertools import batched
from typing import overload, Self

from ._lax_enum import LaxEnum
from ._static_reader import get_asset


@dataclass(frozen = True)
class LayerCell:
	'''
	Represents a layer cell.
	
	``value`` must be a single character.
	'''
	
	row: int
	column: int
	value: str
	
	def __post_init__(self) -> None:
		if len(self.value) != 1:
			raise ValueError('"value" must be a single character')
	
	@property
	def is_transparent(self) -> bool:
		'''
		Whether the value contains only whitespaces.
		'''
		
		return self.value.strip() == ''


_GridOfStrings = Sequence[Sequence[str]]


class Layer:
	
	r'''
	A rectangle grid of :class:`LayerCell`\ s.
	'''
	
	__slots__ = ('_cells', '_height', '_width')
	
	_cells: list[LayerCell]
	_height: int
	_width: int
	
	def __new__(cls, argument: _GridOfStrings) -> 'Self':  # PY-62301
		'''
		Construct a :class:`Layer`.
		
		:param argument: A grid of strings. Cannot be a string itself.
		'''
		
		if isinstance(argument, str):
			raise TypeError('"rows" must not be a string')
		
		instance = super().__new__(cls)
		grid = [list(row) for row in argument]
		first_row = grid[0]
		
		same_width = all(len(row) == len(first_row) for row in grid)
		
		if not same_width:
			raise ValueError('All rows must have the same width')
		
		instance._height = len(grid)
		instance._width = len(first_row)
		instance._cells = []
		
		for row_index, row in enumerate(grid):
			for column_index, cell_value in enumerate(row):
				cell = LayerCell(row_index, column_index, cell_value)
				
				instance._cells.append(cell)
		
		return instance
	
	def __repr__(self) -> str:
		horizontal_frame = f'+{'-' * self._width}+'
		
		joined_rows = (
			f'|{''.join(cell.value for cell in row)}|'
			for row in self.rows()
		)
		
		return '\n'.join([
			horizontal_frame,
			*joined_rows,
			horizontal_frame
		])
	
	@overload
	def __getitem__(self, item: int) -> LayerCell:
		...
	
	@overload
	def __getitem__(self, item: tuple[int, int]) -> LayerCell:
		...
	
	def __getitem__(self, item: int | tuple[int, int]) -> LayerCell:
		if isinstance(item, int):
			return self._cells[item]
		
		if isinstance(item, tuple) and len(item) == 2:
			row_index, column_index = item
			
			if row_index >= self._height or column_index >= self._width:
				raise IndexError('Row or column index is out of bounds')
			
			index = self._width * row_index + column_index
			
			return self[index]
		
		raise TypeError(f'Invalid index')
	
	def __iter__(self) -> Iterator[LayerCell]:
		yield from self._cells
	
	def __len__(self) -> int:
		return len(self._cells)
	
	def __eq__(self, other: object) -> bool:
		if not isinstance(other, Layer):
			return NotImplemented
		
		return self._cells == other._cells
	
	def __add__(self, other: Layer) -> Layer:
		if not isinstance(other, Layer):
			return NotImplemented
		
		copied = self.copy()
		copied += other
		
		return copied
	
	def __iadd__(self, other: Layer) -> Self:
		if not isinstance(other, Layer):
			return NotImplemented
		
		if self.height != other.height or self.width != other.width:
			raise ValueError(
				'To be added, two layers must have the same height and width'
			)
		
		copy_cell = dataclasses.replace
		
		for index, other_cell in enumerate(other):
			if not other_cell.is_transparent:
				self._cells[index] = copy_cell(other_cell)
		
		return self
	
	@property
	def height(self) -> int:
		'''
		The height of the layer.
		'''
		
		return self._height
	
	@property
	def width(self) -> int:
		'''
		The width of the layer.
		'''
		
		return self._width
	
	@classmethod
	def from_text(cls, text: str, width: int | None = None) -> Self:
		'''
		Construct a :class:`Layer` from a piece of text.
		
		:param text: Any string, with one or more lines.
		:param width: \
			The width of the layer.
		:return: A new :class:`Layer`.
		'''
		
		if width is None:
			width = -1
		
		lines = text.splitlines()
		longest_line_length = max(len(line) for line in lines)
		
		width = max([longest_line_length, width])
		
		return cls([line.ljust(width) for line in text.splitlines()])
	
	@classmethod
	def from_sequence(cls, cells: Sequence[str], width: int) -> Self:
		'''
		Construct a :class:`Layer` from a sequence of strings.
		
		:param cells: A :class:`Sequence` of strings.
		:param width: \
			The number of cells per chunk. \
			The last chunk is padded with spaces.
		:return: A new :class:`Layer`.
		'''
		
		rows = []
		
		for row in batched(cells, width):
			if len(row) < width:
				padder = ' ' * (width - len(row))
				rows.append([*row, *padder])
			else:
				rows.append(list(row))
		
		return cls(rows)
	
	def rows(self) -> Generator[tuple[LayerCell, ...], None, None]:
		r'''
		Yield a tuple of :class:`LayerCell`\ s for each row.
		'''
		
		yield from batched(self._cells, self._width)
	
	def columns(self) -> Generator[tuple[LayerCell, ...], None, None]:
		r'''
		Yield a tuple of :class:`LayerCell`\ s for each column.
		'''
		
		for column in zip(*self.rows()):
			yield column
	
	def cells(self) -> Generator[LayerCell, None, None]:
		'''
		Synonym of :meth:`__iter__`.
		'''
		
		yield from self
	
	def copy(self) -> Self:
		'''
		Construct a new :class:`Layer` from this one.
		'''
		
		string_cells = [cell.value for cell in self]
		
		return self.__class__.from_sequence(string_cells, self._width)


class Canvas(Collection[Layer]):
	
	r'''
	A collection of :class:`Layers`.
	Its string representation is that of all its layers merged.
	'''
	
	__slots__ = ('_height', '_width', '_layers')
	
	_height: int
	_width: int
	_layers: list[Layer]
	
	def __init__(self, height: int, width: int) -> None:
		'''
		Construct a :class:`Canvas` of given height and width.
		
		:param height: The height of the canvas.
		:param width: The width of the canvas.
		'''
		
		self._height = height
		self._width = width
		self._layers = []
	
	def __str__(self) -> str:
		if not self._layers:
			return '\n'.join([' ' * self._width] * self._height)
		
		first, *others = self._layers
		flattened = sum(others, start = first)
		
		joined_rows = [
			''.join(cell.value for cell in row)
			for row in flattened.rows()
		]
		
		return '\n'.join(joined_rows)
	
	def __contains__(self, layer: object) -> bool:
		return layer in self._layers
	
	def __iter__(self) -> Iterator[Layer]:
		return iter(self._layers)
	
	def __len__(self) -> int:
		return len(self._layers)
	
	@property
	def height(self) -> int:
		'''
		The height of the canvas.
		'''
		
		return self._height
	
	@property
	def width(self) -> int:
		'''
		The width of the canvas.
		'''
		
		return self._width
	
	@property
	def layers(self) -> Generator[Layer, None, None]:
		'''
		Yield every layer the canvas contains.
		'''
		
		for layer in self._layers:
			yield layer
	
	@classmethod
	def from_layer(cls, layer: Layer) -> Self:
		'''
		Construct a :class:`Canvas` from a layer
		using its height and width.
		
		:param layer: A :class:`Layer`.
		:return: A new :class:`Canvas`.
		'''
		
		canvas = cls(height = layer.height, width = layer.width)
		canvas.add_layers(layer)
		
		return canvas
	
	def _fits_layer(self, layer: Layer) -> bool:
		'''
		Whether the layer has same height and width as the canvas.
		
		:param layer: A :class:`Layer`.
		:return: ``True`` if the layer fits, ``False`` otherwise.
		'''
		
		return self._height == layer.height and self._width == layer.width
	
	def add_layers(self, *layers: Layer) -> None:
		r'''
		Add one or more :class:`Layer`\ s to the canvas.
		
		:param layers: One or more :class:`Layer`\ s.
		'''
		
		if not all(self._fits_layer(layer) for layer in layers):
			raise ValueError(
				'Layers must have same height and width as canvas'
			)
		
		self._layers.extend(layers)


_make_80_wide_layer = partial(Layer.from_text, width = 80)


class Component(metaclass = LaxEnum):
	r'''
	Pre-built :class:`Layer`\ s to be used in the game.
	'''
	
	GALLOWS = _make_80_wide_layer(get_asset('gallows.txt'))
	HEAD = _make_80_wide_layer(get_asset('head.txt'))
	TRUNK = _make_80_wide_layer(get_asset('trunk.txt'))
	LEFT_ARM = _make_80_wide_layer(get_asset('left_arm.txt'))
	RIGHT_ARM = _make_80_wide_layer(get_asset('right_arm.txt'))
	LEFT_LEG = _make_80_wide_layer(get_asset('left_leg.txt'))
	RIGHT_LEG = _make_80_wide_layer(get_asset('right_leg.txt'))
	YOU_LOST = _make_80_wide_layer(get_asset('you_lost.txt'))
```

## src/hangman/choice_list.py

```py
from __future__ import annotations

from collections.abc import Generator
from dataclasses import dataclass
from typing import Mapping, NamedTuple, Self

from ._lax_enum import LaxEnum
from .word_list import Level


@dataclass(frozen = True, slots = True)
class Choice:
	'''
	Represents a valid choice of a :class:`ChoiceList`.
	'''
	
	shortcut: str
	description: str
	aliases: frozenset[str]
	value: str | None = None
	
	def __str__(self) -> str:
		return f'[{self.shortcut}] {self.description}'


_ChoiceDescriptor = tuple[str, set[str], str | None]


class ChoiceDescriptor(NamedTuple):
	'''
	Syntactic sugar for a bare tuple containing three elements:
	``description``, ``aliases``, and ``value``.
	'''
	
	description: str
	aliases: set[str] = set()
	value: str | None = None


class _LengthList(list[int]):
	'''
	A list of integers which keeps track of the sum.
	Meant for internal use only.
	'''
	
	total: int
	
	def __new__(cls) -> 'Self':  # PY-62301
		instance = super().__new__(cls)
		instance.total = 0
		
		return instance
	
	def append(self, value: int) -> None:
		'''
		Append an integer value to the end of
		the list and add it the total.
		
		:param value: A length.
		'''
		self.total += value
		super().append(value)


class ChoiceList:
	
	__slots__ = ('_shortcut_map', '_alias_map', 'max_width', 'separator')
	
	_shortcut_map: dict[str, Choice]
	_alias_map: dict[str, Choice]
	
	separator: str
	max_width: int
	
	def __new__(
		cls, /,
		argument: Mapping[str, _ChoiceDescriptor] | None = None, *,
		separator: str = '  ',
		max_width: int = 80,
		**kwargs: _ChoiceDescriptor
	) -> 'Self':  # PY-62301
		r'''
		Construct a list of valid choices whose string
		representation looks like the following::
		
			[A] Foobar bazqux  [BAR] Lorem ipsum
			[C] Consectetur adipiscing elit
		
		All shorcuts and aliases are case-insensitive and
		mapped to their corresponding :class:`Choice`.
		Shortcuts are uppercased in the string representation.
		
		A :class:`Choice` can be chosen by referencing
		either ``shortcut`` or any of the ``aliases``.
		
		``argument`` and ``kwargs`` are shortcut-to-:class:`ChoiceDescriptor`
		maps. Each ``shortcut`` *should* be a single character,
		whereas the ``description``\ s need to be human-readable.
		``value`` is the value the choice represents,
		defaults to ``None``.
		
		:param argument: A :class:``collections.abc.Mapping``.
		:param separator: \
			A string to be used as the separator
			in the string representation.
		:param max_width: \
			The maximum width of the string
			representation, in characters.
		:param kwargs: \
			Other shortcut-to-descriptor arguments.
		'''
		
		if isinstance(argument, Mapping):
			kwargs = {**argument, **kwargs}
		
		instance = super().__new__(cls)
		
		instance.separator = separator
		instance.max_width = max_width
		
		shortcut_map = instance._shortcut_map = {}
		alias_map = instance._alias_map = {}
		
		for shortcut, (description, aliases, value) in kwargs.items():
			shortcut = shortcut.upper()
			uppercased_aliases = frozenset(alias.upper() for alias in aliases)
			
			choice = Choice(
				shortcut, description,
				uppercased_aliases, value
			)
			
			shortcut_map[shortcut] = choice
			
			for alias in uppercased_aliases:
				alias_map[alias] = choice
		
		return instance
	
	def __contains__(self, item: object) -> bool:
		if not isinstance(item, str):
			return False
		
		item = item.upper()
		
		return item in self._shortcut_map or item in self._alias_map
	
	def __getitem__(self, item: str) -> Choice:
		item = item.upper()
		
		if item in self._shortcut_map:
			return self._shortcut_map[item]
		
		return self._alias_map[item]
	
	def __str__(self) -> str:
		output: list[list[str]] = [[]]
		lengths: list[_LengthList] = [_LengthList()]
		
		for choice in self:
			choice_stringified = str(choice)
			choice_length = len(choice_stringified)
			
			total_choice_length = lengths[-1].total + choice_length
			total_separator_length = len(self.separator) * len(lengths[-1])
			new_last_row_length = total_choice_length + total_separator_length
			
			if new_last_row_length > self.max_width:
				output.append([])
				lengths.append(_LengthList())
			
			output[-1].append(choice_stringified)
			lengths[-1].append(choice_length)
		
		return '\n'.join(self.separator.join(row) for row in output)
	
	def __repr__(self) -> str:
		return (
			f'{self.__class__.__name__}(' +
			', '.join(repr(choice) for choice in self) +
			f')'
		)
	
	def __iter__(self) -> Generator[Choice, None, None]:
		yield from self._shortcut_map.values()


class Choices(metaclass = LaxEnum):
	'''
	Pre-built instances of :class:`ChoicesList`.
	'''
	
	CONFIRMATION = ChoiceList(
		Y = ChoiceDescriptor(
			description = 'Yes',
			aliases = {'Yes'},
			value = 'YES'
		),
		N = ChoiceDescriptor(
			description = 'No',
			aliases = {'No'},
			value = 'NO'
		)
	)
	LEVEL = ChoiceList(
		E = ChoiceDescriptor(
			description = 'Easy (22.5k words)',
			aliases = {'EASY'},
			value = Level.EASY
		),
		M = ChoiceDescriptor(
			description = 'Medium (74.5k words)',
			aliases = {'MEDIUM'},
			value = Level.MEDIUM
		),
		H = ChoiceDescriptor(
			description = 'Hard (168k words)',
			aliases = {'HARD'},
			value = Level.HARD
		),
		U = ChoiceDescriptor(
			description = 'Unix (205k words)',
			aliases = {'UNIX'},
			value = Level.UNIX
		)
	)
```

## src/hangman/conversation.py

```py
from __future__ import annotations

from collections.abc import Callable, Generator
from dataclasses import dataclass
from typing import ClassVar, Literal

from .choice_list import ChoiceList


def _response_is_valid_choice(
	response: str,
	choices: ChoiceList | None
) -> bool:
	'''
	Checks if the response is a valid choice.
	'''
	
	assert choices is not None
	
	return response in choices


def _no_op(_response: str, _choices: ChoiceList | None) -> Literal[True]:
	'''
	A validator that always returns ``True``.
	'''
	
	return True


@dataclass(frozen = True, slots = True, eq = False)
class Validator:
	'''
	Callable wrapper for a validator function.
	The second argument is the warning message
	to be output when this validator fails.
	'''
	
	predicate: ResponseValidator
	warning: str
	
	def __call__(self, response: str, choices: ChoiceList | None) -> bool:
		return self.predicate(response, choices)


InputGetter = Callable[[str], str]
OutputDisplayer = Callable[[str], None]
ResponseValidator = Callable[[str, ChoiceList | None], bool]

OneOrManyValidators = Validator | list[Validator]

_FailingValidators = Generator[Validator, None, None]


class Conversation:
	
	'''
	Protocol for input-output operations.
	'''
	
	_INVALID_RESPONSE: ClassVar[str] = \
		'Invalid response. Please try again.'
	_INVALID_CHOICE: ClassVar[str] = \
		'Invalid choice. Please try again.'
	
	__slots__ = ('_input', '_output')
	
	_input: InputGetter
	_output: OutputDisplayer
	
	def __init__(self, ask: InputGetter, answer: OutputDisplayer) -> None:
		'''
		Construct a :class:`Conversation`.
		
		:param ask: A ``input``-like callable to be called for inputs.
		:param answer: A ``print``-like callable to be called to output.
		'''
		
		self._input = ask
		self._output = answer
	
	def _get_response(self, prompt: str) -> str:
		'''
		Get a raw response.
		
		:param prompt: The prompt to be used.
		:return: The response, uppercased.
		'''
		
		return self._input(prompt).upper()
	
	def _ask(
		self,
		prompt: str, /,
		choices: ChoiceList | None = None, *,
		validators: list[Validator]
	) -> str:
		'''
		Get a response, then validate it against the validators.
		Repeat this process until the response passes all validations.
		'''
		
		failing_validators: Callable[[], _FailingValidators] = lambda: (
			validator for validator in validators
			if not validator(response, choices)
		)
		find_first_failing_validator: Callable[[], Validator | None] = \
			lambda: next(failing_validators(), None)
		
		response = self._get_response(prompt)
		
		while failing_validator := find_first_failing_validator():
			self.answer(failing_validator.warning)
			response = self._get_response(prompt)
		
		return response
	
	def ask(
		self,
		question: str, /,
		choices: ChoiceList | None = None, *,
		until: OneOrManyValidators | None = None
	) -> str:
		r'''
		Thin wrapper around :meth:`_ask`.
		
		If ``choices`` is given, it will be included in
		the prompt text. If ``until`` is ``None``, a
		default validator will be used to check if
		the response is a valid choice.
		
		If both ``choices`` and ``until`` are ``None``,
		no validators will be applied.
		
		:param question: The question to ask.
		:param choices: The choices to choose from. Optional.
		:param until: \
			A :class:`Callable`, a :class:`Validator` or
			a list of :class:`Validator`\ s. Optional.
		:return: The response of the user.
		'''
		
		prompt = f'{question}\n'
		prompt += f'{choices}\n' if choices is not None else ''
		
		if choices is None and until is None:
			validators = [Validator(_no_op, '')]
		elif until is None:
			validators = [
				Validator(_response_is_valid_choice, self._INVALID_CHOICE)
			]
		else:
			validators = [until] if callable(until) else until
		
		return self._ask(
			prompt, choices,
			validators = validators
		)
	
	def answer(self, answer: str) -> None:
		'''
		Outputs the caller's message.
		
		:param answer: The message to output.
		'''
		
		self._output(answer)
```

## src/hangman/game.py

```py
from __future__ import annotations

from typing import ClassVar

from ._static_reader import get_asset
from .canvas import Canvas, Component, Layer
from .choice_list import ChoiceList, Choices
from .conversation import (
	Conversation,
	InputGetter,
	OneOrManyValidators,
	OutputDisplayer,
	Validator
)
from .word import Word
from .word_list import Level, WordList


def _response_is_ascii_letter(character: str, _: ChoiceList | None) -> bool:
	return len(character) == 1 and 'A' <= character <= 'Z'


class Game:
	
	'''
	The Hangman game.
	'''
	
	TITLE: ClassVar[str] = get_asset('title.txt')
	INSTRUCTIONS: ClassVar[str] = get_asset('instructions.txt')
	
	COEFFICENTS: ClassVar[dict[Level, int]] = {
		Level.EASY: 1,
		Level.MEDIUM: 2,
		Level.HARD: 3,
		Level.UNIX: 4
	}
	
	_MAX_DISPLAY_WIDTH: ClassVar[int] = 80
	
	__slots__ = (
		'_conversation', '_used_words', '_points',
		'_reward', '_penalty', '_ended'
	)
	
	_conversation: Conversation
	_used_words: set[str]
	_points: int
	_reward: int
	_penalty: int
	_ended: bool
	
	def __init__(
		self,
		input_getter: InputGetter = input,
		output_displayer: OutputDisplayer = print,
		reward: int = 2,
		penalty: int = -1
	) -> None:
		'''
		Initialize a new game.
		
		See :class:`Conversation` for more information on
		``input_getter`` and ``output_displayer``.
		
		:param input_getter: \
			An ``input``-like function. Defaults to ``input``.
		:param output_displayer: \
			A ``print``-like function. Defaults to ``print``.
		:param reward: \
			The number of points to be added to
			the total on each correct guess.
		:param penalty: \
			The number of points to be subtracted from
			the total on each incorrect guess.
		'''
		
		self._used_words = set()
		self._points = 0
		self._reward, self._penalty = reward, penalty
		
		self._ended = False
		
		self._conversation = Conversation(
			ask = input_getter,
			answer = output_displayer
		)
	
	@property
	def points(self) -> int:
		'''
		The total points earned in this game.
		'''
		
		return self._points
	
	@points.setter
	def points(self, value: int) -> None:
		'''
		Called on operations such as the following:
		
			game.points += 1
		
		The number of points cannot be negative.
		'''
		
		self._points = max(value, 0)
	
	def _start(self) -> None:
		'''
		Output the title, the instructions, then start
		the first :class:`GameRound`. If that round is
		won and the user wants to continue, start another.
		
		Otherwise, if the game has not ended (user did
		not lose in the latest round), end the game.
		'''
		
		self._output_game_title()
		self._output_game_instructions()
		
		self._start_round()
		
		while not self._ended and self._prompt_for_continue_confirmation():
			self._start_round()
		
		if not self._ended:
			self.end()
	
	def _output_game_title(self) -> None:
		'''
		Output the title, which is just some fancy ASCII art.
		'''
		
		self.output(self.TITLE)
	
	def _output_game_instructions(self) -> None:
		'''
		Output the instructions.
		'''
		
		self.output(self.INSTRUCTIONS)
	
	def _prompt_for_continue_confirmation(self) -> bool:
		'''
		Ask for a response until it is a yes/no answer.
		
		:return: Whether the user wants to continue.
		'''
		
		answer = self.input('Continue?', Choices.CONFIRMATION)
		
		return answer in ('Y', 'YES')
	
	def _start_round(self) -> None:
		'''
		Ask for a level, construct a :class:`WordList` and
		a coefficient from that level, then get a random
		word that has not been used.
		
		Finally, initialize a :class:`GameRound` by passing
		the word and the coefficent to it.
		'''
		
		level = self._prompt_for_level()
		word_list = WordList.from_level(level)
		coefficient = self.COEFFICENTS[level]
		
		word = word_list.get_random_word()
		
		while word in self._used_words:
			word = word_list.get_random_word()
		
		self._used_words.add(word)
		
		game_round = self._initialize_round(word, coefficient)
		game_round.start()
	
	def _initialize_round(self, word: str, coefficient: int) -> GameRound:
		'''
		Pass ``word`` and ``coefficient`` as arguments
		to :class:`GameRound`.
		'''
		
		return GameRound(self, word, coefficient)
	
	def _prompt_for_level(self) -> Level:
		'''
		Ask for a response until it is a valid level.
		
		:return: The corresponding :class:`Level`.
		'''
		
		choices = Choices.LEVEL
		response = self._conversation.ask('Choose a level:', choices)
		
		value = choices[response].value
		
		assert value is not None
		
		return Level(value)
	
	def start(self) -> None:
		'''
		Start the game. If a :class:`KeyboardInterrupt`
		is caught, call :meth:`end`.
		'''
		
		try:
			self._start()
		except KeyboardInterrupt:
			self.end()
	
	def end(self) -> None:
		'''
		Switch a boolean flag and call
		:meth:`output_current_points`.
		'''
		
		self._ended = True
		self.output('Game over.'.center(self._MAX_DISPLAY_WIDTH, '-'))
		self.output_current_points()
	
	def input(
		self,
		prompt: str,
		choices: ChoiceList | None = None,
		validators: OneOrManyValidators | None = None
	) -> str:
		'''
		Shorthand for ``self.conversation.ask``.
		'''
		
		return self._conversation.ask(prompt, choices, until = validators)
	
	def output(self, answer: str) -> None:
		'''
		Shorthand for ``self.conversation.answer``.
		'''
		
		return self._conversation.answer(answer)
	
	def output_current_points(self) -> None:
		'''
		Output the total number of points earned.
		'''
		
		self.output(f'Points: {self._points}')
	
	def reward_correct_guess(self, count: int, coefficient: int) -> None:
		'''
		Add ``reward`` multiplied by ``coefficient`` and ``count``
		to the number of points.
		'''
		
		self.points += self._reward * count * coefficient
	
	def penalize_incorrect_guess(self, coefficient: int) -> None:
		'''
		Substract ``penalty`` multiplied by ``coefficient``
		from the number of points.
		'''
		
		self.points += self._penalty * coefficient


class GameRound:
	
	'''
	A game round. The game ends when
	a game round ends with a loss.
	'''
	
	_INVALID_GUESS: ClassVar[str] = \
		'Invalid guess. Please input a letter.'
	_ALREADY_GUESSED: ClassVar[str] = \
		'You have already guessed this letter. Please try again.'
	
	__slots__ = (
		'_game', '_canvas', '_layer_stack',
		'_word', '_coefficient', '_guesses'
	)
	
	_game: Game
	_canvas: Canvas
	_layer_stack: list[Layer]
	_word: Word
	_coefficient: int
	_guesses: set[str]
	
	def __init__(self, game: Game, word: str, coefficient: int) -> None:
		'''
		Initialize a game round.
		
		There are initially 6 layers in the stack.
		Each incorrect guess pops one from the stack
		and adds it to the canvas. When the stack
		reaches 0, the entire game is over.
		
		See :class:`Word` for relevant checking logic.
		
		:param game: The game this round belongs to.
		:param word: The word to guess in this round.
		:param coefficient: \
			The coefficient corresponding to the level of this round.
		'''
		
		self._game = game
		self._canvas = Canvas.from_layer(Component.GALLOWS)
		self._layer_stack = [
			Component.HEAD,
			Component.TRUNK,
			Component.LEFT_ARM,
			Component.RIGHT_ARM,
			Component.LEFT_LEG,
			Component.RIGHT_LEG
		]
		self._word = Word(word)
		self._coefficient = coefficient
		self._guesses = set()
	
	@property
	def lives_left(self) -> int:
		'''
		The number of layers left in the stack.
		'''
		
		return len(self._layer_stack)
	
	def _output_canvas(self) -> None:
		'''
		Output the canvas with all components
		lost via incorrect guesses.
		'''
		
		self._game.output(str(self._canvas))
	
	def _output_current_word_state(self) -> None:
		'''
		Output the word with unknown characters masked.
		'''
		
		self._game.output(f'Word: {self._word.current_state}')
	
	def _output_word(self) -> None:
		'''
		Output the word. Only called when the game is over.
		'''
		
		self._game.output(f'The word was "{self._word}".')
	
	def _start_turn(self) -> None:
		'''
		Call :meth:`_output_canvas` and :meth:`_output_current_word_state`.
		Ask for a new guess, then check it against
		the word and handle the result accordingly.
		'''
		
		self._output_canvas()
		self._output_current_word_state()
		
		guess = self._prompt_for_guess()
		count = self._word.count(guess)
		
		self._guesses.add(guess)
		
		if count == 0:
			self._handle_incorrect_guess()
		else:
			self._handle_correct_guess(guess, count)
	
	def _handle_incorrect_guess(self) -> None:
		'''
		Output a notice, then call :meth:`Game.penalize_incorrect_guess`
		and :meth:`Game.output_current_points`.
		
		Also call :meth:`_minus_1_life`. If the number of lives left
		is 0, add :attr:`Component.YOU_LOST` to the canvas.
		'''
		
		self._game.output('Incorrect guess.')
		self._game.penalize_incorrect_guess(self._coefficient)
		self._game.output_current_points()
		
		self._minus_1_life()
		
		if self.lives_left == 0:
			self._canvas.add_layers(Component.YOU_LOST)
	
	def _handle_correct_guess(self, guess: str, count: int) -> None:
		'''
		Output a notice, then call :meth:`Game.reward_correct_guess`
		and :meth:`Game.output_current_points`.
		
		:param guess: The character guessed.
		:param count: The number of that character's appearances in the word.
		'''
		
		if count == 1:
			self._game.output(f'There is {count} "{guess}"!')
		else:
			self._game.output(f'There are {count} "{guess}"s!')
		
		self._game.reward_correct_guess(count, self._coefficient)
		self._game.output_current_points()
	
	def _prompt_for_guess(self) -> str:
		'''
		Ask for a new guess which must be an ASCII letter.
		
		:return: The guess.
		'''
		
		validators = [
			Validator(_response_is_ascii_letter, self._INVALID_GUESS),
			Validator(self._not_previously_guessed, self._ALREADY_GUESSED)
		]
		
		return self._game.input('Your guess:', validators = validators)
	
	def _not_previously_guessed(
		self,
		response: str,
		_choices: ChoiceList | None
	) -> bool:
		'''
		Check if ``response`` is a previous guess.
		Meant to be called in :meth:`_prompt_for_guess`.
		
		:param response: The response to check.
		:return: Whether ``response`` is a previous guess.
		'''
		
		return response not in self._guesses
	
	def _minus_1_life(self) -> None:
		'''
		Pops a layer from the stack and add it to the canvas.
		'''
		
		self._canvas.add_layers(self._layer_stack.pop(0))
	
	def start(self) -> None:
		'''
		While the word is not completely solved and there
		are still some lives left, start a turn.
		
		If there are no lives left (user lost the game),
		call :meth:`_output_canvas` and :meth:`_output_word`,
		then :meth:`Game.end`.
		Otherwise, :meth:`_output_current_word_state`.
		'''
		
		while not self._word.all_clear and self.lives_left:
			self._start_turn()
		
		if not self.lives_left:
			self._output_canvas()
			self._output_word()
			self._game.end()
		else:
			self._output_current_word_state()
```

## src/hangman/word.py

```py
class Word:
	
	'''
	A word being guessed.
	'''
	
	__slots__ = ('value', '_character_indices', '_masked')
	
	value: str
	_masked: list[str]
	_character_indices: dict[str, list[int]]
	
	def __init__(self, value: str) -> None:
		self.value = value.upper()
		self._masked = ['_'] * len(value)
		self._character_indices = {}
		
		for index, character in enumerate(self.value):
			self._character_indices.setdefault(character, []).append(index)
	
	def __str__(self) -> str:
		return self.value
	
	def __contains__(self, item: str) -> bool:
		return item.upper() in self._character_indices
	
	@property
	def current_state(self) -> str:
		'''
		The letters, space-separated; unguessed
		ones are replaced with underscores.
		'''
		
		return ' '.join(self._masked)
	
	@property
	def all_clear(self) -> bool:
		'''
		Whether all letters have been guessed correctly.
		'''
		
		return all(char != '_' for char in self._masked)
	
	def count(self, guess: str) -> int:
		'''
		Count the guess's appearances in the word
		and replace those with underscores in the
		current state.
		
		:param guess: A letter.
		:return: The number of its appearances.
		'''
		
		if guess not in self:
			return 0
		
		indices = self._character_indices[guess]
		
		for index in indices:
			self._masked[index] = guess
		
		return len(indices)
```

## src/hangman/word_list.py

```py
import random
from enum import StrEnum
from os import PathLike
from typing import ClassVar, final, Self

from ._static_reader import word_list_directory


class Level(StrEnum):
	EASY = 'EASY'
	MEDIUM = 'MEDIUM'
	HARD = 'HARD'
	UNIX = 'UNIX'


@final
class WordList:
	
	_instances: ClassVar[dict[str, Self]] = {}
	
	__slots__ = tuple(['_list'])
	
	_list: list[str]
	
	def __new__(cls, filename: str | PathLike[str]) -> 'Self':  # PY-62301
		filename = str(filename)
		
		if filename not in cls._instances:
			cls._instances[filename] = instance = super().__new__(cls)
			
			with open(filename) as file:
				instance._list = file.read().splitlines()
		
		return cls._instances[filename]
	
	def __len__(self) -> int:
		return len(self._list)
	
	def get_random_word(self) -> str:
		return random.choice(self._list)
	
	@classmethod
	def from_list(cls, words: list[str]) -> Self:
		instance = super().__new__(cls)
		instance._list = words
		
		return instance
	
	@classmethod
	def from_level(cls, level: str) -> Self:
		match level.upper():
			case Level.EASY:
				filename = 'easy.txt'
			case Level.MEDIUM:
				filename = 'medium.txt'
			case Level.HARD:
				filename = 'hard.txt'
			case Level.UNIX:
				filename = 'unix.txt'
			case _:
				raise ValueError('No such level')
		
		return cls(word_list_directory / filename)
```


  [1]: https://github.com/InSyncWithFoo/hangman