Draw Together: Building a Multiplayer Sketch Game with Daphne, NextJS & OpenAI!
Zach
Posted on January 14, 2024
Let's use the OpenAI API to build a multiplayer sketching game!
In this guide, we'll build a Django and NextJS app that allows two players to connect and share a sketch. OpenAI will supply the drawing prompts that we'll give to the players.
If you're more of a visual learner, I've also made a video where I give an overview of the tutorial.
Django App Setup
Creating the Django project.
django-admin startproject djdoodle
cd djdoodle/
python3 manage.py startapp core
Updating the djdoodle/settings.py
file to add the core
application.
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"core",
]
Creating the requirements.txt
file.
Django==4.1.7
daphne==4.0.0
channels==4.0.0
channels-redis==4.1.0
websockets==11.0.3
openai==1.6.1
Creating the Docker file for the Django app.
FROM python:3.11
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
WORKDIR /usr/src/app
COPY requirements.txt /usr/src/app/
RUN pip install --no-cache-dir -r requirements.txt
COPY . /usr/src/app/
RUN mkdir -p /usr/src/app/
Now we'll create the initial docker-compose.yml
to manage the containers. We'll eventually add NextJS, but let's start with Django.
version: "3.7"
services:
daphne:
build:
context: .
command: daphne -b 0.0.0.0 -p 8000 djdoodle.asgi:application
ports:
- "8000:8000"
volumes:
- $PWD:/usr/src/app
The build
and context
keys tell Compose that it can find our Dockerfile
in the current directory.
Building the Django app image.
docker-compose build
Running the initial version as a test.
docker-compose up -d
docker ps
You should see output similar to this.
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
555ef0c2ad40 djdoodle-daphne "daphne -b 0.0.0.0 -…" 2 seconds ago Up 1 second 0.0.0.0:8000->8000/tcp, :::8000->8000/tcp djdoodle-daphne-1
NextJS App Setup
Inside the base project directory, create the NextJS application.
Name the app djdoodle-ui
and select JavaScript as the language when prompted.
npx create-next-app@latest
Creating the Dockerfile
for the NextJS app. The file should be under the djdoodle-ui
directory.
FROM node:18-alpine
WORKDIR /usr/src/app
COPY package.json ./
RUN npm install
COPY . .
CMD ["npm", "run", "dev"]
Now we update the docker-compose.yml
file to include the NextJS app.
version: "3.7"
services:
daphne:
build:
context: .
command: daphne -b 0.0.0.0 -p 8000 djdoodle.asgi:application
ports:
- "8000:8000"
volumes:
- $PWD:/usr/src/app
nextjs:
build:
context: ./djdoodle-ui
ports:
- "3000:3000"
volumes:
- $PWD/djdoodle-ui:/usr/src/app
Build and start the new NextJS image. Run the following in the project base directory.
docker-compose build
docker-compose up -d
You should now be able to access Django at http://localhost:8000
and NextJS at http://localhost:3000
with your browser.
Integrating Django and NextJS
Next we need to allow NextJS and Django to communicate.
We'll use NGINX to present the entire application on one network port. NGINX will proxy requests to Django or NextJS based on the URL path.
Open nginx.conf
and add the following.
server {
listen 80;
location /api {
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://daphne:8000;
}
location / {
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://nextjs:3000;
}
}
Now update the docker-compose.yml
file.
version: "3.7"
services:
daphne:
build:
context: .
command: daphne -b 0.0.0.0 -p 8000 djdoodle.asgi:application
volumes:
- $PWD:/usr/src/app
nextjs:
build:
context: ./djdoodle-ui
volumes:
- $PWD/djdoodle-ui:/usr/src/app
nginx:
image: nginx:latest
ports:
- "8000:80"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
- $PWD:/usr/src/app
Now you should be able to visit http://localhost:8000
and http://localhost:8000/api
to see NextJS and Django respectively.
Adding Django Channels
The Django Channels package, which handles websockets, uses Redis to store connection and message data.
We need to update settings.py
to tell django-channels
where to find Redis.
The ASGI_APPLICATION
variable must also be declared. We'll add the djdoodle/asgi.py
file in a moment.
WSGI_APPLICATION = "djdoodle.wsgi.application"
ASGI_APPLICATION = "djdoodle.asgi.application"
CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels_redis.core.RedisChannelLayer",
"CONFIG": {
"hosts": [(os.environ["REDIS_HOST"], 6379)],
"capacity": 1500,
},
},
}
Django Channels uses Redis to store message data.
We'll update docker-compose.yml
to include a Redis image and add an environment variable to the daphne
container.
version: "3.7"
services:
daphne:
build:
context: .
command: daphne -b 0.0.0.0 -p 8000 djdoodle.asgi:application
environment:
- REDIS_HOST=redis
volumes:
- $PWD:/usr/src/app
depends_on:
- redis
nextjs:
build:
context: ./djdoodle-ui
volumes:
- $PWD/djdoodle-ui:/usr/src/app
redis:
image: redis:latest
ports:
- "6379:6379"
nginx:
image: nginx:latest
ports:
- "8000:80"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
- $PWD:/usr/src/app
Implementing the Game Server Logic
Create a new directory to contain the async server logic.
mkdir core/ws_consumers
Add the following to the core/ws_consumers/draw.py
module.
import json
import asyncio
from django.conf import settings
from openai import OpenAI
from channels.generic.websocket import AsyncWebsocketConsumer
client = OpenAI(api_key=settings.OPENAI_API_KEY)
# An initial message to send to OpenAI that explains the concept of the
# game and the kind of responses we want.
system_prompt = """
There is a game in which two players both work to complete a simple drawing
according to a series of prompts that build on one another.
Each player receives a series of distinct prompts. The prompts should be
related in that they both apply to the same drawing, but the prompts
should be designed so that they apply to different parts of the drawing.
For instance, one player might receive a series of prompts around drawing
the sky, while the other player is drawing the ground. This is only an example.
Please generate your own theme.
Please generate two series of prompts in JSON format, one for each player.
The JSON should have a player1 key and a player2 key. The value of each key
should be an array of strings.
"""
class DrawConsumer(AsyncWebsocketConsumer):
# The game prompts are fetched from OpenAI when the first player connects.
# We need to avoid re-fetching when the second player connects, and also
# make sure both players are sent their prompts at the same time.
# The first player to connect will acquire the fetch_lock while the second
# player will block on fetch_lock until the prompts are ready.
fetch_lock = asyncio.Lock()
prompts_fetched = False
countdown_time = 30
countdown_active = False
# Track the connection count as an easy way to know which set of prompts
# to send when a new connection is established.
connection_count = 0
player1_prompts = []
player2_prompts = []
async def start_countdown(self):
"""
Starts a countdown timer and sends an update of how many seconds
remain to each connected Websocket client.
The timer will restart when it reaches zero.
"""
if not DrawConsumer.countdown_active:
DrawConsumer.countdown_active = True
while DrawConsumer.countdown_time > 0:
await asyncio.sleep(1)
await self.channel_layer.group_send(
"draw_group",
{
"type": "countdown_message",
"countdown": DrawConsumer.countdown_time,
},
)
DrawConsumer.countdown_time -= 1
if DrawConsumer.countdown_time == 0:
DrawConsumer.countdown_time = 30
async def fetch_prompts(self):
"""
Fetch prompts from the OpenAI API.
The first caller will trigger the API request while subsequent calls
block on the fetch_lock, eventually returning immediately because the
prompts_fetched bool has been set to True.
"""
async with DrawConsumer.fetch_lock:
if not DrawConsumer.prompts_fetched:
DrawConsumer.prompts_fetched = True
completion = client.chat.completions.create(
model="gpt-4-1106-preview",
response_format={"type": "json_object"},
messages=[
{
"role": "user",
"content": system_prompt,
}
],
)
data = json.loads(completion.choices[0].message.content)
DrawConsumer.player1_prompts = data["player1"]
DrawConsumer.player2_prompts = data["player2"]
async def connect(self):
"""
Set up Websocket connection and make OpenAI request if this is the
first connection. Distributes prompts to players.
"""
DrawConsumer.connection_count += 1
await self.channel_layer.group_add("draw_group", self.channel_name)
await self.fetch_prompts()
await self.accept()
prompts = [DrawConsumer.player1_prompts, DrawConsumer.player2_prompts]
await self.send(
text_data=json.dumps(
{
"type": "prompts",
"prompts": prompts[DrawConsumer.connection_count % 2],
}
)
)
asyncio.create_task(self.start_countdown())
async def disconnect(self, close_code):
await self.channel_layer.group_discard("draw_group", self.channel_name)
async def receive(self, text_data):
"""
Broadcast messages received from one player back to themselves and to
the other player in the game.
"""
data = json.loads(text_data)
await self.channel_layer.group_send(
"draw_group", {"type": "draw_message", "message": data}
)
async def draw_message(self, event):
message = event["message"]
await self.send(text_data=json.dumps(message))
async def countdown_message(self, event):
countdown = event["countdown"]
message = {"type": "countdown", "countdown": countdown}
await self.send(text_data=json.dumps(message))
Now we'll wire up the routing for the DrawConsumer
class. Open core/ws_routing.py
and add the following.
from django.urls import path
from core.ws_consumers.draw import DrawConsumer
ws_urlpatterns = [
path("api/ws/draw/", DrawConsumer.as_asgi()),
]
Update the djdoodle/asgi.py
file so Daphne is aware of our DrawConsumer
endpoint.
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "djdoodle.settings")
application = get_asgi_application()
from channels.routing import ProtocolTypeRouter, URLRouter
from core.ws_routing import ws_urlpatterns
application = ProtocolTypeRouter(
{
"websocket": URLRouter(ws_urlpatterns),
}
)
For the above to work, you'll need to add your OpenAI API key to the settings.py
file.
OPENAI_API_KEY = "your key here"
Implementing the NextJS UI
To get started, open djdoodle-ui/app/drawing.js
and add the following.
This code will tie all the components together and manage communication with Django over the websocket.
"use client";
import React, { useState, useEffect, useRef } from "react";
import CanvasComponent from "./canvas";
import CountdownDisplay from "./countdown";
import ColorPicker from "./picker";
import Prompt from "./prompt";
import styles from "./drawing.module.css";
const startCountdown = 30;
const DrawingApp = () => {
const [drawData, setDrawData] = useState([]);
const [isPromptVisible, setIsPromptVisible] = useState(false);
const [countdown, setCountdown] = useState(startCountdown);
const [prompts, setPrompts] = useState([]);
const [promptMessage, setPromptMessage] = useState("");
const [color, setColor] = useState("#00ff00");
const ws = useRef(null);
useEffect(() => {
// Show the prompt popup with the next prompt once
// the timer rolls back around to 30 seconds.
if (countdown == 30 && prompts.length) {
setPromptMessage(prompts.shift());
setIsPromptVisible(true);
}
}, [prompts, countdown]);
useEffect(() => {
if (!ws.current) {
ws.current = new WebSocket(
`ws://${window.location.hostname}:8000/api/ws/draw/`,
);
ws.current.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === "prompts") {
setPrompts(data.prompts);
} else if (data.type === "countdown") {
setCountdown(data.countdown);
} else {
setDrawData((prevDrawData) => [...prevDrawData, data]);
}
};
}
return () => {
if (ws.current) {
ws.current.close();
ws.current = null;
}
};
}, []);
const handleMouseEvent = (eventType, position) => {
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
ws.current.send(JSON.stringify(position));
}
};
return (
<div>
<CanvasComponent
drawData={drawData}
color={color}
onMouseEvent={handleMouseEvent}
/>
<div className={styles.picker}>
<ColorPicker
color={color}
onColorChange={(newColor) => setColor(newColor)}
/>
</div>
<div className={styles.countdown}>
<CountdownDisplay countdown={countdown} />
</div>
<div className={styles.prompt}>
<Prompt
message={promptMessage}
isVisible={isPromptVisible}
onDismiss={() => setIsPromptVisible(false)}
/>
</div>
</div>
);
};
export default DrawingApp;
The child components include the HTML5 canvas, a countdown timer, a popup for the drawing prompt and a color picker.
Open djdoodle-ui/app/canvas.js
and enter the following for the canvas component.
"use client";
import React, { useState, useRef, useEffect } from "react";
const CanvasComponent = ({ drawData, color, onMouseEvent }) => {
const [isMouseDown, setIsMouseDown] = useState(false);
const canvasRef = useRef(null);
const drawBackground = (ctx, width, height) => {
ctx.fillStyle = "black";
ctx.fillRect(0, 0, width, height);
};
const drawCircle = (ctx, x, y, color) => {
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(x, y, 12, 0, 2 * Math.PI);
ctx.fill();
};
const handleMouseEvent = (event) => {
if (isMouseDown) {
const canvas = canvasRef.current;
const rect = canvas.getBoundingClientRect();
const position = {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
};
onMouseEvent(event.type, { color: color, ...position });
}
if (event.type === "mousedown") {
setIsMouseDown(true);
} else if (event.type === "mouseup") {
setIsMouseDown(false);
}
};
useEffect(() => {
const canvas = canvasRef.current;
const context = canvas.getContext("2d");
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
drawBackground(context, canvas.width, canvas.height);
drawData.forEach(({ x, y, color }) => {
drawCircle(context, x, y, color);
});
}, [drawData]);
useEffect(() => {
const canvas = canvasRef.current;
canvas.addEventListener("mousemove", handleMouseEvent);
canvas.addEventListener("mousedown", handleMouseEvent);
canvas.addEventListener("mouseup", handleMouseEvent);
return () => {
canvas.removeEventListener("mousemove", handleMouseEvent);
canvas.removeEventListener("mousedown", handleMouseEvent);
canvas.removeEventListener("mouseup", handleMouseEvent);
};
}, [isMouseDown]);
return <canvas ref={canvasRef} />;
};
export default CanvasComponent;
Now we'll add the countdown timer in the djdoodle-ui/app/countdown.js
file.
"use client";
import React from "react";
import styles from "./countdown.module.css";
const CountdownDisplay = ({ countdown }) => {
return <div className={styles.countdown}>{countdown}</div>;
};
export default CountdownDisplay;
Open and update djdoodle-ui/app/prompt.js
to create the Prompt component.
import React, { useState } from "react";
import styles from "./prompt.module.css";
const Prompt = ({ message, isVisible, onDismiss }) => {
if (!isVisible) return null;
return (
<div className={styles.prompt}>
<p>{message}</p>
<button onClick={onDismiss}>OK</button>
</div>
);
};
export default Prompt;
Finally, we'll add the following to the djdoodle-ui/app/picker.js
file to create the color picker component.
import React from "react";
const ColorPicker = ({ color, onColorChange }) => {
const handleColorChange = (event) => {
onColorChange(event.target.value);
};
return (
<div>
<input
type="color"
id="color-picker"
name="color"
value={color}
onChange={handleColorChange}
/>
</div>
);
};
export default ColorPicker;
Next Steps
This has hopefully been a fun way to explore Daphne and websockets!
There are many ways to improve and build upon this simple example:
- Add a game lobby where players are matched up
- Add more drawing tools for the players
- Improve the websocket communication to be more efficient
- Make the app ready for a production deployment
You can view the full code for the app on GitHub.
While most of the code was shared here, a few parts such as CSS were omitted to make this guide shorter.
Posted on January 14, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
January 14, 2024