Connect Four with local play and a networked server/clients.
Project description
Getting Started
A terminal Connect Four game in Python. This guide gets you playing locally and shows the mechanics of writing your own bot. (For playing over a network with a server and clients, see NETWORKED_PLAY.md.)
Requirements
- Python 3.13 or newer — check with
python3 --version. - No packages to install — it uses only the standard library.
Run all commands from the project folder (the one with local.py and the
bot/ directory). If python3 isn't found, try python.
Play a local game
Local play runs everything in one program. Start a human-vs-human game:
python3 local.py
You'll enter a name, then see the board:
0 1 2 3 4 5 6
+-+-+-+-+-+-+-+
| | | | | | | |
| | | | | | | |
| | | | | | | |
| | | | | | | |
| | | | | | | |
| | | | | | | |
+-+-+-+-+-+-+-+
The numbers on top are column numbers, starting at 0. On your turn, type a
column number and press Enter to drop your piece; it falls to the lowest open
slot. Get win_length pieces in a row (horizontal, vertical, or diagonal) to
win. Ctrl-C quits.
Choosing players
RED always moves first, then YELLOW. Use --red and --yellow to set each
color to either human or a bot name:
python3 local.py # human vs human (default)
python3 local.py --yellow leftmost # you (RED) vs leftmost bot
python3 local.py --red leftmost --yellow rightmost # bot vs bot
The bots included so far are deliberately simple — they're your competition to beat:
| Name | Strategy |
|---|---|
leftmost |
Plays the left-most column with room. |
rightmost |
Plays the right-most column with room. |
rand |
Plays a random column with room. |
invalid |
Always plays an out-of-bounds column (for testing error handling). |
Changing the board and win condition
| Option | Default | Meaning |
|---|---|---|
--cols |
7 |
Board width |
--rows |
6 |
Board height |
--win-length |
4 |
Pieces in a row needed to win |
# Same as the default:
python3 local.py --cols 7 --rows 6 --win-length 4
# A small, fast "three in a row" board — handy while testing a bot:
python3 local.py --cols 5 --rows 4 --win-length 3 --red leftmost --yellow rand
win_length must be at least 1 and can't exceed both the width and height
(otherwise no win is possible); the game refuses impossible combinations.
Accessible display
The board is drawn with emoji disks by default. If that's hard to read — you use
a screen reader, can't tell the colors apart, or your terminal renders the
fullwidth grid poorly — pick a different style with --display:
--display |
Looks like |
|---|---|
emoji |
The colored-disk grid above (the default). |
ascii |
A plain grid using letters — R, Y, and . for empty — with | + - borders and ordinary digit headers. Pieces are told apart by letter, not color. |
text |
No grid at all: each column is described in words, bottom to top — read aloud cleanly by a screen reader. |
python3 local.py --display ascii
python3 local.py --display text --yellow leftmost
The same --display flag works for networked play (python3 client.py --display text). It only changes what you see; both players can choose
different styles in the same game.
ascii shows the board above as:
0 1 2 3 4 5 6
+-+-+-+-+-+-+-+
|.|.|.|.|.|.|.|
|.|.|.|.|.|.|.|
|.|.|.|.|.|.|.|
|.|.|.|.|.|.|.|
|.|.|.|.|.|.|.|
|.|.|.|.|.|.|.|
+-+-+-+-+-+-+-+
and text describes each column, e.g. Column 3: red, yellow. (listed from the
bottom up, the order pieces were dropped).
How a game flows (the rules as a state machine)
Every game — local or networked — is run by one referee (Game.play() in
game.py). It is the single source of truth for the rules: turn order, what
makes a move legal, and how a game ends. Both the local game and the server are
thin shells that just ask play() what happens next, so the rules can't drift
apart between the two modes.
play() loops over one turn at a time. Each turn asks the current player
(RED first, then alternating) for a column, then branches on what comes back:
stateDiagram-v2
direction TB
[*] --> AskCurrentPlayer: RED moves first
AskCurrentPlayer --> ApplyMove: legal column
AskCurrentPlayer --> IllegalMove: illegal / malformed column
AskCurrentPlayer --> GameOver: forfeit / timeout
IllegalMove --> AskCurrentPlayer: ask again (same player)
IllegalMove --> GameOver: 5th illegal move
ApplyMove --> GameOver: four in a row, or board full
ApplyMove --> AskCurrentPlayer: otherwise, other player's turn
GameOver --> [*]
Reading the same thing in plain text, if your terminal doesn't render the diagram above:
+------>+-----------------------------+
| | Ask current player |-- forfeit / timeout --> GAME OVER
| | for a column |
| +-----------------------------+
| | |
| legal column illegal / malformed
| | |
| v v
| +-----------+ +--------------+
| | Apply move| | Illegal move |-- 5th illegal move --> GAME OVER
| +-----------+ +--------------+
| | |
| win / board full |
| | |
| v |
| GAME OVER |
+--- no win: other player's +-- still your turn: ask again
turn (back to top) (back to top)
The five ways a game can end (the end reason, reported to both players and
in the server's [GAME] log):
| End reason | What happened |
|---|---|
WIN |
A player got win_length in a row — they win. |
TIE |
The board filled up with no winner. |
TOO_MANY_INVALID |
A player made 5 illegal moves in one turn (MAX_INVALID_MOVES); the opponent wins. |
TIMEOUT |
A player ran out of time on a move (networked play only); the opponent wins. |
FORFEIT |
A player couldn't move — disconnected, or a bot crashed; the opponent wins. |
What this means for a bot:
- You're only ever asked for a move on your turn, and you get a fresh copy of the board, so you can't corrupt the real game.
- An illegal column (out of range, or a full column) isn't fatal on its own — you're simply asked again. But the count resets only after a legal move, so five bad columns in a single turn loses the game. Return a playable column.
- A bot that raises an exception forfeits that game (the opponent wins) rather than crashing the program; locally you'll also see the traceback to debug.
Write your own bot
A bot is just one function that, given the current board and which piece you're playing, returns the column number to play. The rest of this section is the plumbing; the strategy is up to you.
1. Create a file in bot/
Add a file bot/<name>.py. Any file you drop in the bot/ folder is picked up
automatically when the program starts (except files beginning with _ or
test_). The name you register is the name you'll use on the command line.
Here is the entire leftmost bot (bot/leftmost.py) — the simplest complete
example to model yours on:
from game import Board, Piece
from . import register
@register("leftmost")
def leftmost(board: Board, piece: Piece) -> int:
for c in range(board.cols):
if board.is_column_playable(c):
return c
raise ValueError("Board appears full")
The pieces that make it a bot:
from . import register— pulls in the registration helper from thebotpackage.@register("leftmost")— registers the function under a name. Use a unique name for yours, e.g.@register("mybot").- The function signature must be
(board: Board, piece: Piece) -> int. - It returns a column number (an
int). It does not place the piece itself — the game does that.
2. What your function gets, and what it returns
board— the current position. Read it to decide your move (see the toolbox below). Treat it as read-only.piece—Piece.REDorPiece.YELLOW, whichever you are playing.- Return — the column number to drop into,
0toboard.cols - 1. Pick a column that still has room. If you return a full or out-of-range column the server counts it as an illegal move, so check first.
3. The board toolbox
These are the methods and values you'll use to inspect the position:
| Tool | What it gives you |
|---|---|
board.cols, board.rows |
Board dimensions. |
board.win_length |
Pieces in a row needed to win (may not be 4!). |
board.is_column_playable(c) |
True if column c has room. |
board.get_next_open_row(c) |
Row a new piece in column c would land on (None if full). |
board[c][r] |
The Piece at column c, row r (Piece.EMPTY if empty). Row 0 is the bottom. |
board.check_win(piece) |
True if piece already has a winning line. |
board.is_full() |
True if no moves remain. |
piece.opponent() |
The other player's piece. |
board.copy() |
A separate copy you can experiment on. |
board.move_piece(c, piece) |
Drops piece into column c on that board. |
The last two are how you can try out a move without disturbing the real game:
copy the board, call move_piece on the copy, and inspect the result (for
example with check_win). The real board the game hands you is unchanged.
4. Run and test your bot
Once bot/mybot.py exists, use it like any other bot:
python3 local.py --red mybot --yellow leftmost
python3 local.py --red mybot --yellow rand --cols 5 --rows 4 --win-length 3
You can also call the function directly to test specific situations. Set up a
position with move_piece, then assert your bot picks the column you expect:
import unittest
from game import Board, Piece
import bot
class TestMyBot(unittest.TestCase):
def test_picks_a_playable_column(self):
mybot = bot.strict_lookup("mybot") # raises if "mybot" never registered
board = Board()
board.move_piece(3, Piece.RED) # set up any position you like
choice = mybot(board, Piece.YELLOW)
self.assertTrue(board.is_column_playable(choice))
if __name__ == "__main__":
unittest.main()
Run your tests with:
python3 -m unittest discover -p "test_*.py"
5. Now it's your turn
The example bots never look at where the pieces are — that's the bar to clear. Some questions to get you thinking (no code provided on purpose):
- Can your bot notice when it has a move that wins right now, and take it?
- Can it notice when the opponent is about to win, and block?
- Which column is worth more when nothing urgent is happening?
- How would you handle a board whose
win_lengthisn't 4?
Start small, play it against leftmost and rand, and grow it from there.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file jkh_c4-0.1.4.tar.gz.
File metadata
- Download URL: jkh_c4-0.1.4.tar.gz
- Upload date:
- Size: 69.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
10432b50fb0b643c171f50e9fa1c61701c1c7bb368269b18994ca124ecf4444d
|
|
| MD5 |
9a1c7bfd261358ccd131d0af4eb8f097
|
|
| BLAKE2b-256 |
13caa5c13f28e7fedaa1f33ed22daa999ff1a2fea7a155289d4c19c45592f04d
|
Provenance
The following attestation bundles were made for jkh_c4-0.1.4.tar.gz:
Publisher:
release.yml on jkh52/c4
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
jkh_c4-0.1.4.tar.gz -
Subject digest:
10432b50fb0b643c171f50e9fa1c61701c1c7bb368269b18994ca124ecf4444d - Sigstore transparency entry: 1770565937
- Sigstore integration time:
-
Permalink:
jkh52/c4@20cb848db74866049cc96a83722c1b98a281f063 -
Branch / Tag:
refs/tags/v0.1.4 - Owner: https://github.com/jkh52
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@20cb848db74866049cc96a83722c1b98a281f063 -
Trigger Event:
push
-
Statement type:
File details
Details for the file jkh_c4-0.1.4-py3-none-any.whl.
File metadata
- Download URL: jkh_c4-0.1.4-py3-none-any.whl
- Upload date:
- Size: 42.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
119d35c85b79fdee07e614195275baf512ea21226bb8b45c240892a667ac74c8
|
|
| MD5 |
8b5cb62586868f720a846983e687f6a6
|
|
| BLAKE2b-256 |
18a95594a14abf94189c09d918808379e09b8a0e0444b299450e0cd0b33e60fb
|
Provenance
The following attestation bundles were made for jkh_c4-0.1.4-py3-none-any.whl:
Publisher:
release.yml on jkh52/c4
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
jkh_c4-0.1.4-py3-none-any.whl -
Subject digest:
119d35c85b79fdee07e614195275baf512ea21226bb8b45c240892a667ac74c8 - Sigstore transparency entry: 1770566055
- Sigstore integration time:
-
Permalink:
jkh52/c4@20cb848db74866049cc96a83722c1b98a281f063 -
Branch / Tag:
refs/tags/v0.1.4 - Owner: https://github.com/jkh52
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@20cb848db74866049cc96a83722c1b98a281f063 -
Trigger Event:
push
-
Statement type: