Draw Together: Building a Multiplayer Sketch Game with Daphne, NextJS & OpenAI!
Tags: python, websocket, nextjs, javascript
Published: 2024-01-14 14:26:07

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.

{% embed https://www.youtube.com/watch?v=FptgQST5RfM %}

### Django App Setup

Creating the Django project.

```bash
django-admin startproject djdoodle
cd djdoodle/
python3 manage.py startapp core
```

Updating the `djdoodle/settings.py` file to add the `core` application.

```python
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.

```bash
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.

```bash
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.

```bash
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.

```bash
docker-compose build
```

Running the initial version as a test.

```bash
docker-compose up -d
docker ps
```

You should see output similar to this.

```bash
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.

```bash
npx create-next-app@latest
```

Creating the `Dockerfile` for the NextJS app.  The file should be under the `djdoodle-ui` directory.

```bash
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.

```bash
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.

```bash
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.

```bash
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.

```bash
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.

```python
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.

```bash
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.

```bash
mkdir core/ws_consumers
```

Add the following to the `core/ws_consumers/draw.py` module.

```python
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.

```python
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.

```python
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.

```python
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.
 
```javascript
"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.

```javascript
"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.

```javascript
"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.

```javascript
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.

```javascript
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:

<ul>
  <li>Add a game lobby where players are matched up</li>
  <li>Add more drawing tools for the players</li>
  <li>Improve the websocket communication to be more efficient</li>
  <li>Make the app ready for a production deployment</li>
</ul>

<a href="https://github.com/zchtodd/djdoodle">You can view the full code for the app on GitHub.</a>

While most of the code was shared here, a few parts such as CSS were omitted to make this guide shorter.