WebSockets have become the go-to technology for enabling real-time, bidirectional communication between client and server. 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.

Django Websockets in Action

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

Below, you'll find an interactive HTML5 canvas. You'll notice that the sprite rotates to follow your mouse cursor, and left clicking moves your ship in that direction. Best of all, everyone is seeing the same game. Every reader of this post has their own sprite that is added to the canvas. You can always open a few extra tabs to see how it works with multiple players if no one else is reading at the moment.

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.

Press and hold left mouse button to move your sprite around the canvas.

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.

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


<!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.


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.


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.


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

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

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.


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) 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.


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.


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.

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.


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.


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.


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.


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

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.


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 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, you should be able to issue the docker-compose up -d command to bring up both containers in the background.

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:

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

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.


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)

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.

In short, we'll build a normal React component and use R2WC to perform the conversion.

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


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 <canvas ref={canvasRef} />;
};

export default CanvasComponent;

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.


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.

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.

For an additional learning resource on Django and Websockets, I recommend John Doherty's video on building a real-time booking system.