Django Websockets
Tags: django, python, websockets
Published: 2023-09-06 22:12:24

While many tutorials demonstrate how to implement WebSockets in a Django app through chat applications, we'll take a different route in this guide. 

We'll lean into the real-time capabilities of WebSockets to create a simple multiplayer game. For this tutorial, we'll use React on the frontend and leverage Django Channels and Daphne on the backend to handle the WebSocket connections.

- [1. Django Websockets in Action](#1-django-websockets-in-action)
- [2. Creating the Django Project](#2-creating-the-django-project)
- [3. Setting Up the Django Template](#3-setting-up-the-django-template)
- [4. Django Channels and Daphne](#4-django-channels-and-daphne)
    - [4.1 Routing and WebsocketConsumer](#41-routing-and-websocketconsumer)
    - [4.2 Using NGINX and Daphne in Production](#42-using-nginx-and-daphne-in-production)
    - [4.3 Implementing Game Logic in the Daphne Consumer](#43-implementing-game-logic-in-the-daphne-consumer)
- [5. Setting Up the React Game Component](#5-setting-up-the-react-game-component)
- [6. Load Testing the Daphne Server](#6-load-testing-the-daphne-server)

### <a id="1-django-websockets-in-action"></a>Django Websockets in Action

Before we dive into the tutorial, I'd like to share a live demo of the multiplayer game! 

[You can find the live demo here.](https://circumeo.io/blog/entry/django-websockets/)

While simple, this game demonstrates that Django Channels and Daphne can support highly interactive real-time applications.  Continue reading to see how you can implement something similar.

### <a id="2-creating-the-django-project"></a>Creating the Django Project

In this section you will set up the Django project and verify that all prerequisites are in place.

Begin by using the **django-admin** tool to create a new Django project.

```
django-admin startproject multiplayer_channels_demo
```

Now create a new application within the **multiplayer_channels_demo** directory.

```
cd hero_builder
python3 manage.py startapp channels_demo
```

Run the initial migrations for the SQLite development database.

```
python3 manage.py migrate
```

Start the development server in order to verify that everything is working so far.

```
python3 manage.py runserver
```

You should be able to visit **http://localhost:8000** now.  Django will serve a placeholder page.

<img src="https://circumeo.io/static/images/djangostart.png" style="width: 100%"/>

### <a id="3-setting-up-the-django-template"></a>Setting Up the Django Template

Even though our focus is on Websockets, we still need a Django template to serve the initial page.  That first page will contain JavaScript that opens a Websocket connection.

If you haven't already, you'll need to create a template directory for the **channels_demo** app.

```
mkdir -p channels_demo/templates/channels_demo
```

Now we'll create the **index.html** template inside the new templates directory.  The template contains a custom element named **multiplayer-channels-demo** that will create the HTML5 canvas and communicate with Django.  

The component is built with React but has been converted into a [Web Component](https://developer.mozilla.org/en-US/docs/Web/API/Web_components) so that it can be dropped into the template with minimal dependencies.  The React runtime is loaded from a CDN, as the custom element doesn't include those dependencies.  

We'll go into greater depth on the React component later in the tutorial.

```html
<!DOCTYPE html>
{% load static %}
<html>
  <head>
    <meta charset="utf-8" />
    <link rel="stylesheet" href="{% static 'channels_demo/css/index.css' %}"/>

    <script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
    <script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>

    <script src="{% static 'channels_demo/js/main.js' %}"></script>

    <title>Multiplayer Demo</title>
  </head>
  <body>
    <multiplayer-channels-demo></multiplayer-channels-demo>
  </body>
</html>
```

With the template created, we need routing and a view function.  Open **views.py** in the **channels_demo** directory and insert the following.

```python
from django.shortcuts import render

def index(request):
    return render(request, "channels_demo/index.html")
```

We also need a **urls.py** file for the app.  Create one under **channels_demo** and update it with the following.

```python
from django.urls import path
from . import views

urlpatterns = [
    path("", views.index, name="index"),
]
```

Lastly, the **urls.py** file under **multiplayer_channels_demo** needs to be updated to include the new routes.

```python
from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path("multiplayer/", include("channels_demo.urls")),
    path("admin/", admin.site.urls),
]
```

### <a id="4-django-channels-and-daphne"></a>Django Channels and Daphne

So far we have a vanilla Django project.  Next we'll add Django Channels and Daphne to our application.

We'll use a **requirements.txt** file to manage the project dependencies.  Create a file named **requirements.txt** within your top-level project folder and place the following in it.

```
Django==4.1.7
daphne==4.0.0
channels==4.0.0
```

Use pip to install the dependencies before moving further with the tutorial.

```
pip install -r requirements.txt
```

Next we need to update **settings.py** to include Daphne and the demo app in the **INSTALLED_APPS** list.  Note that Daphne must be listed first in order to properly extend the **runserver** management command.

```python
INSTALLED_APPS = [ 
    "daphne",
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "channels_demo",
]
```

One more addition is needed in **settings.py** before we can move on.  Daphne requires an entrypoint that satisfies the [Asynchronous Server Gateway Interface (ASGI)](https://asgi.readthedocs.io/en/latest/introduction.html) in order to serve our Django application.  Luckily, new Django projects come standard with an **asgi.py** file that we can use for this, but we need to tell Daphne how to find it.

```python
ASGI_APPLICATION = "multiplayer_channels_demo.asgi.application"
```

Lastly, we need a minimal setup inside the **asgi.py** file to get started.  This minimal setup won't have any Websocket functionality initially, but will allow Daphne to serve regular Django views.  Open the file and write the following.

```python
import os

from channels.routing import ProtocolTypeRouter
from django.core.asgi import get_asgi_application

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "multiplayer_channels_demo.settings")

application = ProtocolTypeRouter(
    {
        "http": get_asgi_application(),
    }
)
```

You should be able to launch the development server again, but this time Daphne will act as the server.

```
python3 manage.py runserver
```
 
The output should look similar to the first time, but if you look closely, you should see that Daphne is listed as the server.

```
 Performing system checks...

System check identified no issues (0 silenced).
September 03, 2023 - 15:56:03
Django version 4.1.7, using settings 'multiplayer_channels_demo.settings'
Starting ASGI/Daphne version 4.0.0 development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.
```

### <a id="41-routing-and-websocketconsumer"></a>Routing and WebsocketConsumer

Now that the essentials are in place, we'll add a Websocket consumer and the associated routing.  In Django Channels terminology, a consumer is much like a normal Django view.  Likewise, routing for consumers is normally done separately from the **urls.py** file that we typically see in every Django project.  Open a new file under the **channels_demo** directory named **routing.py** and insert the following.

```python
from django.urls import re_path
from . import consumers

websocket_urlpatterns = [
    re_path(r"ws/game/$", consumers.MultiplayerConsumer.as_asgi()),
]
```

Next we'll define the simplest possible **WebsocketConsumer** class that we can use to get started.  Open **consumers.py** in the same directory as the **routing.py** file and enter the following.

```python
import json
import uuid

from channels.generic.websocket import WebsocketConsumer
from channels.layers import get_channel_layer
from asgiref.sync import async_to_sync

class MultiplayerConsumer(WebsocketConsumer):
    game_group_name = "game_group"
    players = {}

    def connect(self):
        self.player_id = str(uuid.uuid4())
        self.accept()

        async_to_sync(self.channel_layer.group_add)(
            self.game_group_name, self.channel_name
        )

        self.send(
            text_data=json.dumps({"type": "playerId", "playerId": self.player_id})
        )

    def disconnect(self, close_code):
        async_to_sync(self.channel_layer.group_discard)(
            self.game_group_name, self.channel_name
        )

    def receive(self, text_data):
        text_data_json = json.loads(text_data)
```

This is a very basic consumer, but it illustrates the important methods.  There is a dedicated **MultiplayerConsumer** instance per connection.  The **connect** method is called on initial connection, **disconnect** when the connection is broken, and **receive** whenever a message is received from the websocket.  The **send** method communicates directly with the connected client.  It's used in the code above to give new players their own unique player ID.

Before we can connect at this endpoint, however, we need to update **asgi.py** to handle routing websocket connections.  Up to this point, Channels has only been set up to handle normal views.  Open the **asgi.py** file and make the following update.

```python
import os

from channels.routing import ProtocolTypeRouter, URLRouter
from django.core.asgi import get_asgi_application

from channels_demo.routing import websocket_urlpatterns

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "multiplayer_channels_demo.settings")

application = ProtocolTypeRouter(
    {
        "http": get_asgi_application(),
        "websocket": URLRouter(websocket_urlpatterns),
    }
)
```

The **URLRouter** class is responsible for delegating requests to the correct consumer.

One more tweak is needed before this basic setup will function.  The code in the **consumers.py** file makes use of Channel layers by referring to the **self.channel_layer** variable that's inherited from the **WebsocketConsumer** class.  The **channel_layer** allows our code to organize connections into groups and broadcast messages to those groups.  

Before we can do any of that, however, Django Channels requires that we define a backend.  The Channels backend is responsible for storing connection info.  If you have a single server and you don't care about losing connections on a restart, then the **InMemoryChannelLayer** might be sufficient.  For multiple servers and persistence, however, you may want to use the **RedisChannelLayer** instead.

Add the following to **settings.py** to make use of the **InMemoryChannelLayer** for now.

```python
CHANNEL_LAYERS = { 
    "default": {
        "BACKEND": "channels.layers.InMemoryChannelLayer"
    }   
}
```

### <a id="42-using-nginx-and-daphne-in-production"></a>Using NGINX and Daphne in Production

Now we'll take a moment to make this setup ready for production.  Daphne is production ready, but it typically isn't used to serve static files or provide SSL termination.

We can use Docker to create a consistent environment for both development and production.  The first step is to create a Dockerfile to represent the application and its dependencies.

```python
FROM python:3.8

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/

CMD ["daphne", "multiplayer_channels_demo.asgi:application", "-u", "/usr/src/app/daphne.sock"]
```
 
The Daphne server will read and write from **daphne.sock** instead of listening on a port.  NGINX will forward requests to the socket file.  Open a file named **nginx.conf** and insert the following.

```
worker_processes 1;

events {
    worker_connections 1024;
}

http {
    include mime.types;

    server {
        listen 8443 ssl http2;

        ssl_certificate /etc/letsencrypt/live/your_domain_name/fullchain.pem;
        ssl_certificate_key /etc/letsencrypt/live/your_domain_name/privkey.pem;
        ssl_prefer_server_ciphers off;

        location / {
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;

            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection "upgrade";

            proxy_pass http://unix:/usr/src/app/daphne.sock;
        }

        location /static {
            alias /usr/src/app/staticfiles;
        }
    }
}
```

Be sure to update the **your_domain_name** placeholder with your SSL certificate path.  You can use [LetsEncrypt to obtain a certificate](https://letsencrypt.org/getting-started/) quickly and easily.  If you don't wish to use SSL, it's easy to strip that out and listen for plain HTTP connections.  This setup forwards all requests other than those starting with **/static** to the Daphne server.

Now we'll create a file named **docker-compose.yml** to tie all this together.  We'll have an app container and an NGINX container.  The containers will share a volume so that both have access to the same **daphne.sock** file.

```
version: "3.7"

services:
  app:
    build:
      context: .
    restart: unless-stopped
    command: daphne multiplayer_channels_demo.asgi:application -u /usr/src/app/daphne.sock
    volumes:
      - $PWD:/usr/src/app

  nginx:
    image: nginx:latest
    restart: always
    ports:
      - "8443:8443"
    volumes:
      - /etc/letsencrypt:/etc/letsencrypt
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
      - $PWD:/usr/src/app
    depends_on:
      - app
```

With the above in place and [Docker Compose installed](https://www.digitalocean.com/community/tutorials/how-to-install-and-use-docker-compose-on-ubuntu-20-04), you should be able to issue the **docker-compose up -d** command to bring up both containers in the background.

### <a id="43-implementing-game-logic-in-the-daphne-consumer"></a>Implementing Game Logic in the Daphne Consumer

We have a containerized Django and Daphne application, but it doesn't do much other than accept connections at this point.

Now we'll fill out the game logic in the **MultiplayerConsumer** class that we created earlier.  The logic consists of a few important concepts:

<ul>
  <li>New connections receive a unique player ID.</li>
  <li>The client sends mouse down and mouse release events to Django.</li>
  <li>The client sends the angle between the mouse cursor and player sprite to Django every 50ms.</li>
  <li>The game server performs simple physics updates on a 50ms interval.</li>
  <li>Django broadcasts the position and facing angle of all sprites to all connections every 50ms.</li>
</ul>

In a real game, the client would most likely also do its own physics calculations, with updates from the game server taking precedence.  In this way, the game can appear to keep functioning even with lag.  When the connection is restored, then the state of the game can be reconciled with the server.

For this simple example, however, all physics calculations are done within the **MultiplayerConsumer** class.  In fact, the majority of the code is dedicated to simulation.  The websocket related code is still relatively simple.

```python
import json
import uuid
import asyncio
import math

from channels.generic.websocket import AsyncWebsocketConsumer
from asgiref.sync import async_to_sync

class MultiplayerConsumer(AsyncWebsocketConsumer):
    MAX_SPEED = 5
    THRUST = 0.2

    game_group_name = "game_group"
    players = {}

    update_lock = asyncio.Lock()

    async def connect(self):
        self.player_id = str(uuid.uuid4())
        await self.accept()

        await self.channel_layer.group_add(
            self.game_group_name, self.channel_name
        )

        await self.send(
            text_data=json.dumps({"type": "playerId", "playerId": self.player_id})
        )

        async with self.update_lock:
            self.players[self.player_id] = {
                "id": self.player_id,
                "x": 500,
                "y": 500,
                "facing": 0,
                "dx": 0,
                "dy": 0,
                "thrusting": False,
            }

        if len(self.players) == 1:
            asyncio.create_task(self.game_loop())

    async def disconnect(self, close_code):
        async with self.update_lock:
            if self.player_id in self.players:
                del self.players[self.player_id]

        await self.channel_layer.group_discard(
            self.game_group_name, self.channel_name
        )

    async def receive(self, text_data):
        text_data_json = json.loads(text_data)
        message_type = text_data_json.get("type", "")

        player_id = text_data_json["playerId"]

        player = self.players.get(player_id, None)
        if not player:
            return

        if message_type == "mouseDown":
            player["thrusting"] = True
        elif message_type == "mouseUp":
            player["thrusting"] = False
        elif message_type == "facing":
            player["facing"] = text_data_json["facing"]

    async def state_update(self, event):
        await self.send(
            text_data=json.dumps(
                {
                    "type": "stateUpdate",
                    "objects": event["objects"],
                }
            )
        )

    async def game_loop(self):
        while len(self.players) > 0:
            async with self.update_lock:
                for player in self.players.values():
                    if player["thrusting"]:
                        dx = self.THRUST * math.cos(player["facing"])
                        dy = self.THRUST * math.sin(player["facing"])
                        player["dx"] += dx
                        player["dy"] += dy

                        speed = math.sqrt(player["dx"] ** 2 + player["dy"] ** 2)
                        if speed > self.MAX_SPEED:
                            ratio = self.MAX_SPEED / speed
                            player["dx"] *= ratio
                            player["dy"] *= ratio

                    player["x"] += player["dx"]
                    player["y"] += player["dy"]

            await self.channel_layer.group_send(
                self.game_group_name,
                {"type": "state_update", "objects": list(self.players.values())},
            )
            await asyncio.sleep(0.05)
```

### <a id="5-setting-up-the-react-game-component"></a>Setting Up the React Game Component

You might recall from earlier that the Django template contained a **multiplayer-channels-demo** element.  Behind the scenes, this element is a React component that has been converted into a Web Component.  

I covered this technique in an earlier post on [progressively enhancing Django templates with Web Components.](https://circumeo.io/blog/entry/using-react-to-progressively-enhance-a-django-app/)

In short, we'll build a normal React component and use [R2WC](https://www.bitovi.com/open-source/react-to-web-component) to perform the conversion.

The following is the **CanvasComponent** that handles all client side logic, including communication with the Django backend.

```javascript
import React, { useRef, useEffect, useState } from "react";

const calculateAngle = (playerX, playerY, mouseX, mouseY) => {
  const dx = mouseX - playerX;
  const dy = mouseY - playerY;
  return Math.atan2(dy, dx);
};

const CanvasComponent = () => {
  const canvasRef = useRef(null);
  const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
  const [gameObjects, setGameObjects] = useState([]);
  const [playerId, setPlayerId] = useState(null);
  const gameObjectsRef = useRef(gameObjects);
  const playerIdRef = useRef(playerId);
  const requestRef = useRef(null);
  const moveSpriteRef = useRef(null);
  const idleSpriteRef = useRef(null);
  const wsRef = useRef(null);

  const coordinateWidth = 1000;
  const coordinateHeight = 1000;

  // Load the idle and move sprites that are used to render the players.
  useEffect(() => {
    const idleImage = new Image();
    idleImage.onload = () => (idleSpriteRef.current = idleImage);
    idleImage.src = "/static/channels_demo/images/sprite-idle.png";

    const moveImage = new Image();
    moveImage.onload = () => (moveSpriteRef.current = moveImage);
    moveImage.src = "/static/channels_demo/images/sprite-move.png";
  }, []);

  // Resize the canvas to fit the width and height of the parent
  // container.  The "logical" canvas is always 1000/1000
  // pixels, so a transform is needed when the physical canvas
  // size does not match.
  const resizeCanvas = () => {
    const canvas = canvasRef.current;
    const ctx = canvas.getContext("2d");
    const boundingRect = canvas.parentNode.getBoundingClientRect();

    const pixelRatio = window.devicePixelRatio || 1;

    canvas.width = boundingRect.width;
    canvas.height = boundingRect.height;

    ctx.setTransform(
      (canvas.width / coordinateWidth) * pixelRatio,
      0,
      0,
      (canvas.height / coordinateHeight) * pixelRatio,
      0,
      0
    );
  };

  // Render each player sprite.  Use context save/restore to apply transforms
  // for that sprite specifically without affecting anything else.
  const drawGameObject = (ctx, obj) => {
    if (!moveSpriteRef.current) return;

    const spriteWidth = 187;
    const spriteHeight = 94;

    ctx.save();
    ctx.translate(obj.x, obj.y);
    ctx.rotate(obj.facing);

    const sprite = obj.thrusting
      ? moveSpriteRef.current
      : idleSpriteRef.current;

    ctx.drawImage(
      sprite,
      0,
      0,
      spriteWidth,
      spriteHeight,
      -spriteWidth / 4,
      -spriteHeight / 4,
      spriteWidth / 3,
      spriteHeight / 3
    );
    ctx.restore();
  };

  // Server messages are either game state updates or the initial
  // assignment of a unique playerId for this client.
  const handleWebSocketMessage = (event) => {
    const messageData = JSON.parse(event.data);
    if (messageData.type === "stateUpdate") {
      setGameObjects(messageData.objects);
    } else if (messageData.type === "playerId") {
      setPlayerId(messageData.playerId);
    }
  };

  // Notify game server of mouse down.
  const handleMouseDown = (event) => {
    if (event.button !== 0 || !playerIdRef.current) return;
    wsRef.current.send(
      JSON.stringify({ type: "mouseDown", playerId: playerIdRef.current })
    );
  };

  // Notify game server of mouse release.
  const handleMouseUp = (event) => {
    if (event.button !== 0 || !playerIdRef.current) return;
    wsRef.current.send(
      JSON.stringify({ type: "mouseUp", playerId: playerIdRef.current })
    );
  };

  // Callback for requestAnimationFrame. Clears the canvas and renders the
  // black background before rendering each individual player sprite.
  const animate = (time) => {
    requestRef.current = requestAnimationFrame(animate);

    const canvas = canvasRef.current;
    const ctx = canvas.getContext("2d");

    ctx.clearRect(0, 0, coordinateWidth, coordinateHeight);

    ctx.fillStyle = "black";
    ctx.fillRect(0, 0, coordinateWidth, coordinateHeight);

    gameObjectsRef.current.forEach((obj) => drawGameObject(ctx, obj));
  };

  // Refresh the gameObjectsRef when gameObjects is updated.  This is necessary
  // because the animate callback triggers the "stale closure" problem.
  useEffect(() => {
    gameObjectsRef.current = gameObjects;
  }, [gameObjects]);

  // Refresh the playerIdRef when playerId is updated.  This is necessary
  // because the animate callback triggers the "stale closure" problem.
  useEffect(() => {
    playerIdRef.current = playerId;
  }, [playerId]);

  // Sets up an interval that calculates the angle between the mouse cursor
  // and the player sprite.  Sends that angle to the game server on a 50ms
  // interval.
  useEffect(() => {
    let mousePosition = { x: 0, y: 0 };

    const updateMousePosition = (event) => {
      const canvas = canvasRef.current;
      const boundingRect = canvas.getBoundingClientRect();
      const scaleX = canvas.width / boundingRect.width;
      const scaleY = canvas.height / boundingRect.height;

      mousePosition.x = (event.clientX - boundingRect.left) * scaleX;
      mousePosition.y = (event.clientY - boundingRect.top) * scaleY;
    };

    window.addEventListener("mousemove", updateMousePosition);

    const intervalId = setInterval(() => {
      const playerId = playerIdRef.current;
      const gameObjects = gameObjectsRef.current;

      if (!playerId) return;
      const playerObj = gameObjects.find((obj) => obj.id === playerId);

      if (playerObj) {
        const facing = calculateAngle(
          playerObj.x,
          playerObj.y,
          mousePosition.x * (coordinateWidth / canvasRef.current.width),
          mousePosition.y * (coordinateHeight / canvasRef.current.height)
        );

        wsRef.current.send(
          JSON.stringify({
            type: "facing",
            playerId,
            facing,
          })
        );
      }
    }, 50);

    return () => {
      clearInterval(intervalId);
      window.removeEventListener("mousemove", updateMousePosition);
    };
  }, []);

  // Entrypoint.  Establish websocket, register input callbacks, and start
  // the animation frame cycle.
  useEffect(() => {
    const canvas = canvasRef.current;

    wsRef.current = new WebSocket("ws://localhost:8000/ws/game/");
    wsRef.current.onmessage = handleWebSocketMessage;

    canvas.addEventListener("mousedown", handleMouseDown);
    canvas.addEventListener("mouseup", handleMouseUp);

    requestRef.current = requestAnimationFrame(animate);
    return () => {
      cancelAnimationFrame(requestRef.current);
      canvas.removeEventListener("mousedown", handleMouseDown);
      canvas.removeEventListener("mouseup", handleMouseUp);
      wsRef.current.close();
    };
  }, []);

  // Call resizeCanvas on initial load and also whenever browser is resized.
  useEffect(() => {
    resizeCanvas();
    window.addEventListener("resize", resizeCanvas);

    return () => {
      window.removeEventListener("resize", resizeCanvas);
    };
  }, [containerSize]);

  return &lt;canvas ref={canvasRef} /&gt;;
};

export default CanvasComponent;
```

### <a id="6-load-testing-the-daphne-server"></a>Load Testing the Daphne Server

We have a working game server and client at this point, but how many clients can the server host?

I decided to write a script to load test a single Daphne server behind NGINX, on a host with 1 vCPU.  The load test script simulates multiple client connections, in that it connects and then sends updates on a 50ms interval, just like a real client.  The script begins with one connection and adds an additional connection every second, until it has run for 90 seconds.

```python
from collections import defaultdict

import asyncio
import json
import math
import time
import websockets

time_since_last_update = defaultdict(list)

async def send_facing_updates(websocket, player_id):
    while True:
        current_time = time.time()
        new_facing = math.sin(current_time)

        facing_msg = {"type": "facing", "playerId": player_id, "facing": new_facing}

        await websocket.send(json.dumps(facing_msg))
        await asyncio.sleep(0.05)


async def receive_updates(websocket, client_id, player_id):
    last_update_time = time.time()
    while True:
        message = await websocket.recv()
        data = json.loads(message)

        current_time = time.time()
        delta_time = current_time - last_update_time
        last_update_time = current_time

        time_since_last_update[client_id].append(delta_time)


async def game_client(client_id):
    uri = "ws://localhost:8000/ws/game/"
    async with websockets.connect(uri) as websocket:
        init_msg = await websocket.recv()
        init_data = json.loads(init_msg)
        player_id = init_data.get("playerId", None)

        if player_id is None:
            return

        send_task = send_facing_updates(websocket, player_id)
        receive_task = receive_updates(websocket, client_id, player_id)
        await asyncio.gather(send_task, receive_task)


async def main():
    client_id = 0
    tasks = []
    start_time = time.time()
    while True:
        if time.time() - start_time > 90:
            for task in tasks:
                task.cancel()
            break

        task = asyncio.create_task(game_client(client_id))
        tasks.append(task)

        client_id += 1
        await asyncio.sleep(1)

    await asyncio.gather(*tasks, return_exceptions=True)

    print(json.dumps(time_since_last_update))

if __name__ == "__main__":
    asyncio.get_event_loop().run_until_complete(main())
```

Once the script has completed, it outputs JSON containing the time elapsed between each server update, for every connection.  The time elapsed should be close to 50ms as long as the server is keeping up.  As the load increases, however, the elapsed time begins to exceed 50ms.

Here's a graph of how that test turned out.

<img src="https://circumeo.io/static/images/server_update_latency.png" style="width: 100%"/>

The further to the right on the X axis, the more connections have been opened.  

We start with 1 connection and end with 90 connections.  Aside from a few lag spikes, we stay mostly at 50ms between updates, until the script has opened 50 or so connections.  At that point, the lag would be noticeable, with updates taking 250ms or more.  At the 80 connection mark, lag increases dramatically, with some updates taking well over a full second.  Our game would be pretty unplayable at that point.

### Conclusion

I hope this tutorial has given you some insight into what you can achieve with Django, Django Channels, and Daphne!  Many tutorials focus on use cases like chat apps, and while there's nothing wrong that, it's interesting to push the limits and see just how real-time you can get with Django.