Skip to content

Commit 7f0b2c4

Browse files
committed
Initial implementation.
1 parent 8195c1d commit 7f0b2c4

File tree

8 files changed

+501
-0
lines changed

8 files changed

+501
-0
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
__pycache__

app.py

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
#!/usr/bin/env python
2+
3+
import asyncio
4+
import json
5+
import secrets
6+
7+
from websockets.asyncio.server import broadcast, serve
8+
9+
from connect4 import PLAYER1, PLAYER2, Connect4
10+
11+
12+
JOIN = {}
13+
14+
WATCH = {}
15+
16+
17+
async def error(websocket, message):
18+
"""
19+
Send an error message.
20+
21+
"""
22+
event = {
23+
"type": "error",
24+
"message": message,
25+
}
26+
await websocket.send(json.dumps(event))
27+
28+
29+
async def replay(websocket, game):
30+
"""
31+
Send previous moves.
32+
33+
"""
34+
# Make a copy to avoid an exception if game.moves changes while iteration
35+
# is in progress. If a move is played while replay is running, moves will
36+
# be sent out of order but each move will be sent once and eventually the
37+
# UI will be consistent.
38+
for player, column, row in game.moves.copy():
39+
event = {
40+
"type": "play",
41+
"player": player,
42+
"column": column,
43+
"row": row,
44+
}
45+
await websocket.send(json.dumps(event))
46+
47+
48+
async def play(websocket, game, player, connected):
49+
"""
50+
Receive and process moves from a player.
51+
52+
"""
53+
async for message in websocket:
54+
# Parse a "play" event from the UI.
55+
event = json.loads(message)
56+
assert event["type"] == "play"
57+
column = event["column"]
58+
59+
try:
60+
# Play the move.
61+
row = game.play(player, column)
62+
except ValueError as exc:
63+
# Send an "error" event if the move was illegal.
64+
await error(websocket, str(exc))
65+
continue
66+
67+
# Send a "play" event to update the UI.
68+
event = {
69+
"type": "play",
70+
"player": player,
71+
"column": column,
72+
"row": row,
73+
}
74+
broadcast(connected, json.dumps(event))
75+
76+
# If move is winning, send a "win" event.
77+
if game.winner is not None:
78+
event = {
79+
"type": "win",
80+
"player": game.winner,
81+
}
82+
broadcast(connected, json.dumps(event))
83+
84+
85+
async def start(websocket):
86+
"""
87+
Handle a connection from the first player: start a new game.
88+
89+
"""
90+
# Initialize a Connect Four game, the set of WebSocket connections
91+
# receiving moves from this game, and secret access tokens.
92+
game = Connect4()
93+
connected = {websocket}
94+
95+
join_key = secrets.token_urlsafe(12)
96+
JOIN[join_key] = game, connected
97+
98+
watch_key = secrets.token_urlsafe(12)
99+
WATCH[watch_key] = game, connected
100+
101+
try:
102+
# Send the secret access tokens to the browser of the first player,
103+
# where they'll be used for building "join" and "watch" links.
104+
event = {
105+
"type": "init",
106+
"join": join_key,
107+
"watch": watch_key,
108+
}
109+
await websocket.send(json.dumps(event))
110+
# Receive and process moves from the first player.
111+
await play(websocket, game, PLAYER1, connected)
112+
finally:
113+
del JOIN[join_key]
114+
del WATCH[watch_key]
115+
116+
117+
async def join(websocket, join_key):
118+
"""
119+
Handle a connection from the second player: join an existing game.
120+
121+
"""
122+
# Find the Connect Four game.
123+
try:
124+
game, connected = JOIN[join_key]
125+
except KeyError:
126+
await error(websocket, "Game not found.")
127+
return
128+
129+
# Register to receive moves from this game.
130+
connected.add(websocket)
131+
try:
132+
# Send the first move, in case the first player already played it.
133+
await replay(websocket, game)
134+
# Receive and process moves from the second player.
135+
await play(websocket, game, PLAYER2, connected)
136+
finally:
137+
connected.remove(websocket)
138+
139+
140+
async def watch(websocket, watch_key):
141+
"""
142+
Handle a connection from a spectator: watch an existing game.
143+
144+
"""
145+
# Find the Connect Four game.
146+
try:
147+
game, connected = WATCH[watch_key]
148+
except KeyError:
149+
await error(websocket, "Game not found.")
150+
return
151+
152+
# Register to receive moves from this game.
153+
connected.add(websocket)
154+
try:
155+
# Send previous moves, in case the game already started.
156+
await replay(websocket, game)
157+
# Keep the connection open, but don't receive any messages.
158+
await websocket.wait_closed()
159+
finally:
160+
connected.remove(websocket)
161+
162+
163+
async def handler(websocket):
164+
"""
165+
Handle a connection and dispatch it according to who is connecting.
166+
167+
"""
168+
# Receive and parse the "init" event from the UI.
169+
message = await websocket.recv()
170+
event = json.loads(message)
171+
assert event["type"] == "init"
172+
173+
if "join" in event:
174+
# Second player joins an existing game.
175+
await join(websocket, event["join"])
176+
elif "watch" in event:
177+
# Spectator watches an existing game.
178+
await watch(websocket, event["watch"])
179+
else:
180+
# First player starts a new game.
181+
await start(websocket)
182+
183+
184+
async def main():
185+
async with serve(handler, "", 8001):
186+
await asyncio.get_running_loop().create_future() # run forever
187+
188+
189+
if __name__ == "__main__":
190+
asyncio.run(main())

connect4.css

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/* General layout */
2+
3+
body {
4+
background-color: white;
5+
display: flex;
6+
flex-direction: column-reverse;
7+
justify-content: center;
8+
align-items: center;
9+
margin: 0;
10+
min-height: 100vh;
11+
}
12+
13+
/* Action buttons */
14+
15+
.actions {
16+
display: flex;
17+
flex-direction: row;
18+
justify-content: space-evenly;
19+
align-items: flex-end;
20+
width: 720px;
21+
height: 100px;
22+
}
23+
24+
.action {
25+
color: darkgray;
26+
font-family: "Helvetica Neue", sans-serif;
27+
font-size: 20px;
28+
line-height: 20px;
29+
font-weight: 300;
30+
text-align: center;
31+
text-decoration: none;
32+
text-transform: uppercase;
33+
padding: 20px;
34+
width: 120px;
35+
}
36+
37+
.action:hover {
38+
background-color: darkgray;
39+
color: white;
40+
font-weight: 700;
41+
}
42+
43+
.action[href=""] {
44+
display: none;
45+
}
46+
47+
/* Connect Four board */
48+
49+
.board {
50+
background-color: blue;
51+
display: flex;
52+
flex-direction: row;
53+
padding: 0 10px;
54+
position: relative;
55+
}
56+
57+
.board::before,
58+
.board::after {
59+
background-color: blue;
60+
content: "";
61+
height: 720px;
62+
width: 20px;
63+
position: absolute;
64+
}
65+
66+
.board::before {
67+
left: -20px;
68+
}
69+
70+
.board::after {
71+
right: -20px;
72+
}
73+
74+
.column {
75+
display: flex;
76+
flex-direction: column-reverse;
77+
padding: 10px;
78+
}
79+
80+
.cell {
81+
border-radius: 50%;
82+
width: 80px;
83+
height: 80px;
84+
margin: 10px 0;
85+
}
86+
87+
.empty {
88+
background-color: white;
89+
}
90+
91+
.column:hover .empty {
92+
background-color: lightgray;
93+
}
94+
95+
.column:hover .empty ~ .empty {
96+
background-color: white;
97+
}
98+
99+
.red {
100+
background-color: red;
101+
}
102+
103+
.yellow {
104+
background-color: yellow;
105+
}

connect4.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
const PLAYER1 = "red";
2+
3+
const PLAYER2 = "yellow";
4+
5+
function createBoard(board) {
6+
// Inject stylesheet.
7+
const linkElement = document.createElement("link");
8+
linkElement.href = import.meta.url.replace(".js", ".css");
9+
linkElement.rel = "stylesheet";
10+
document.head.append(linkElement);
11+
// Generate board.
12+
for (let column = 0; column < 7; column++) {
13+
const columnElement = document.createElement("div");
14+
columnElement.className = "column";
15+
columnElement.dataset.column = column;
16+
for (let row = 0; row < 6; row++) {
17+
const cellElement = document.createElement("div");
18+
cellElement.className = "cell empty";
19+
cellElement.dataset.column = column;
20+
columnElement.append(cellElement);
21+
}
22+
board.append(columnElement);
23+
}
24+
}
25+
26+
function playMove(board, player, column, row) {
27+
// Check values of arguments.
28+
if (player !== PLAYER1 && player !== PLAYER2) {
29+
throw new Error(`player must be ${PLAYER1} or ${PLAYER2}.`);
30+
}
31+
const columnElement = board.querySelectorAll(".column")[column];
32+
if (columnElement === undefined) {
33+
throw new RangeError("column must be between 0 and 6.");
34+
}
35+
const cellElement = columnElement.querySelectorAll(".cell")[row];
36+
if (cellElement === undefined) {
37+
throw new RangeError("row must be between 0 and 5.");
38+
}
39+
// Place checker in cell.
40+
if (!cellElement.classList.replace("empty", player)) {
41+
throw new Error("cell must be empty.");
42+
}
43+
}
44+
45+
export { PLAYER1, PLAYER2, createBoard, playMove };

0 commit comments

Comments
 (0)