Draw Together: Building a Multiplayer Sketch Game with Daphne, NextJS & OpenAI!

circumeo

Zach

Posted on January 14, 2024

Draw Together: Building a Multiplayer Sketch Game with Daphne, NextJS & OpenAI!

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
Enter fullscreen mode Exit fullscreen mode

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",
]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Running the initial version as a test.

docker-compose up -d
docker ps
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Build and start the new NextJS image. Run the following in the project base directory.

docker-compose build
docker-compose up -d
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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,                                                              
        },                                                                                 
    },                                                                                     
} 
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Implementing the Game Server Logic

Create a new directory to contain the async server logic.

mkdir core/ws_consumers
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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()),
]
Enter fullscreen mode Exit fullscreen mode

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),
    }
)
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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.

💖 💪 🙅 🚩
circumeo
Zach

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