I created a game engine for Django?
TL;DR: Complete multiplayer game in the browser made of 270 lines of Python and 0 lines of JavaScript running on Django thanks to Django LiveView.
After my adventure in Doom, I asked myself a question. Could I build a simple multiplayer game in Django? Similar to working with tools like Pygame, except the result would be rendered on a web page. Django LiveView could save me from having to write JavaScript and manage WebSockets. All game state would live on the server and rendered HTML is sent to clients via WebSockets. Let's play!
My goal was to create the Snake game, but multiplayer, with multiple players competing on the same board. Each player controls their own snake, trying to eat food and avoid crashing into other snakes or walls. The game lives in a 20x20 cell space, or 400 divs, and updates 10 times per second. That's a total of 4000 divs/second. Pretty easy for a modern browser.
Step 1: Game Loop in Background Thread
Every proper game has a game loop that updates the game state and renders the output. In this case, the loop runs in a separate thread to avoid blocking the main web server:
def loop():
"""Main game loop"""
while True:
sleep(0.1)
update() # Update positions, detect collisions
render() # Broadcast HTML to all clients
def start():
thread = threading.Thread(target=loop, daemon=True)
thread.start()
Step 2: Broadcasting to All Clients
The magic is in sending the rendered HTML grid to all connected clients. Specifically, it would update 10 times per second (10 FPS) since the game doesn't need more fluidity.
def render():
html = render_to_string("components/canvas.html", {
"canvas": game_state["canvas"],
})
data = {
"target": "#canvas",
"html": html,
}
async_to_sync(my_channel_layer.group_send)(
"broadcast", {"type": "broadcast_message", "message": data}
)
Step 3: Capturing Keyboard Events
With Django LiveView, keyboard events can be handled directly in Python. The keyboard mapping uses WASD keys for movement:
<section data-liveview-keyboard-map='{"w":"key_up","a":"key_left","s":"key_down","d":"key_right"}'
data-liveview-focus="true">
Or you can add a button:
<button data-liveview-function="key_up" data-action="click->page#run">☝️</button>
In both cases, the event is handled in Python:
@liveview_handler("key_up")
def key_up(consumer, content):
room_id = getattr(consumer, "room_id", None) or content.get("room")
set_direction(room_id, "up")
Technically speaking, who captures the events is JavaScript (there's no other way), specifically Stimulus or LiveView JS, but all event handling and game logic is done in Python. Django LiveView works with HTML attributes and Python decorators. It would be similar to using htmx except here there's no REST API in between and a single WebSocket connection is used all the time.
Step 4: Player Identification
Now we get into the logic part. How do we identify each player? Every page has a unique UUID generated by LiveView accessible via the template:
{% load liveview %}
<html lang="en" data-room="{% liveview_room_uuid %}">
Which is also accessible in Python since it's assigned to the consumer. The game uses a global shared state accessed by all handlers:
# Global game state (shared by all players)
game_state = {
"canvas": [],
"target": {"x": 10, "y": 10},
"players": {}, # {room_id: {direction, body, color, last_activity}}
}
@liveview_handler("key_up")
def key_up(consumer, content):
room_id = getattr(consumer, "room_id", None) or content.get("room")
set_direction(room_id, "up")
def set_direction(room_id, new_direction):
"""Set the direction for a specific player"""
with game_lock:
if room_id not in game_state["players"]:
_create_player(room_id)
player = game_state["players"][room_id]
# Prevent reverse direction to avoid instant death
opposite = {"up": "down", "down": "up", "left": "right", "right": "left"}
if opposite.get(player["direction"]) != new_direction:
player["direction"] = new_direction
player["last_activity"] = time()
An elegant solution integrated with the framework. Each player is identified by their unique room_id, and the game automatically handles player cleanup.
Step 5: The Game Logic - Update Function
The heart of the game is the update() function, called 10 times per second by the game loop. It handles movement, collisions, food consumption, and inactive player cleanup:
def update():
"""Update all players' positions"""
with game_lock:
# Clean up inactive players (30 second timeout)
current_time = time()
inactive_players = [
room_id for room_id, player in game_state["players"].items()
if current_time - player["last_activity"] > 30
]
for room_id in inactive_players:
del game_state["players"][room_id]
# Move each player's snake
for room_id, player in game_state["players"].items():
head = player["body"][0]
# Calculate new head position (with wrap-around)
if player["direction"] == "left":
new_head = {"x": head["x"], "y": (head["y"] - 1) % HEIGHT}
elif player["direction"] == "right":
new_head = {"x": head["x"], "y": (head["y"] + 1) % HEIGHT}
# ... similar for up/down
# Check if eating food
will_eat = (new_head["x"] == game_state["target"]["x"] and
new_head["y"] == game_state["target"]["y"])
# Move snake: add new head, remove tail (unless eating)
player["body"].insert(0, new_head)
if not will_eat:
player["body"].pop()
else:
game_state["target"] = search_random_free_space()
# Detect collisions (self-collision and with other snakes)
players_to_reset = []
for room_id, player in game_state["players"].items():
new_head = player["body"][0]
# Self-collision
if new_head in player["body"][1:]:
players_to_reset.append(room_id)
# Collision with other snakes
for other_room_id, other_player in game_state["players"].items():
if room_id != other_room_id and new_head in other_player["body"]:
players_to_reset.append(room_id)
break
# Reset collided snakes to random position
for room_id in players_to_reset:
game_state["players"][room_id]["body"] = [search_random_free_space()]
Notice how inactive players are automatically removed after 30 seconds of no activity. This prevents the game from filling up with disconnected players.
Step 6: Dynamic Rendering with Inline Styles
Each canvas cell is represented with a different color, depending on whether it's part of a player's body, food, or empty space. The trick is to use inline styles to define the background and border color of each cell directly in the rendered HTML.
<div style="background-color: {{ col.color }}; border-color: {{ col.color }};"></div>
My complete canvas template looks like this:
{% for rows in canvas %}
{% for col in rows %}
{% if col == "floor" %}
<div class="canvas__cell canvas__floor"></div>
{% elif col.type == "target" %}
<div class="canvas__cell canvas__cell--target"></div>
{% elif col.type == "player" %}
<div class="canvas__cell canvas__cell--player-1" style="background-color: {{ col.color }}; border-color: {{ col.color }};"></div>
{% elif col.type == "player_head" %}
<div class="canvas__cell canvas__cell--head" style="background-color: {{ col.color }}; border-color: {{ col.color }};"></div>
{% endif %}
{% endfor %}
{% endfor %}
Step 7: Testing Locally and Deployment
The first step is to test if it works locally, against myself. I launch 2 instances of my browser and start playing against myself.
It seems to work well. However, it's an unrealistic situation.
- My machine is powerful.
- I have no latency.
- There are only 2 players.
The next step is to deploy it on a humble machine, a Raspberry Pi 3, and play with several friends each from their own home. One of them was on a continent more than 9,700 km away.
One of them played from a mobile with 4G connection.
And despite everything, it worked! The game was perfectly playable.
Performance Metrics
Let's talk numbers. Each broadcast sends the entire 20x20 grid (400 cells) as HTML to all connected clients:
Payload size per update:
- Empty cell:
~47 bytes(<div class="canvas__cell canvas__floor"></div>) - Player cell:
~125 bytes(includes inline styles for color) - Food cell:
~48 bytes
With a mostly empty board, each update sends approximately 18-20 KB of HTML. At 10 FPS, that's 180-200 KB/s per client for broadcast updates.
Conclusions
I'm left with more questions than certainties, I would have loved to play with 10 or 20 simultaneous players to see how the server behaves. But overall, I'm very satisfied with the result. Browsers are just a mirror of the game state that lives on the server, their only job is to render and send new events. This makes it not only easy to maintain but also scalable. I can add new features to the game without having to touch the WebSocket handler code or worry about synchronizing state between client and server.
For me, the successes of using this approach are:
- I don't need to write JavaScript.
- The game state is centralized on the server and is individual per player.
- I only have to work with Django templates.
- WebSocket connections and DOM synchronization are automated.
But not everything is advantages:
- There's no client-side prediction or interpolation, which can be a problem for games that require low latency.
- Broadcasting HTML is heavier than sending JSON with data.
- Poor server performance affects all players.
My intention is not to create games with Django LiveView, but to experiment with realtime capabilities. It's a good tool to add interactivity without complications. And apparently, it can also handle simple multiplayer games.
- Online demo: You can use W D S A to control the snake, but you must first press a direction button. And you'll have to guess what colors you are, sorry!
- Source code
- Step 1: Game Loop in Background Thread
- Step 2: Broadcasting to All Clients
- Step 3: Capturing Keyboard Events
- Step 4: Player Identification
- Step 5: The Game Logic - Update Function
- Step 6: Dynamic Rendering with Inline Styles
- Step 7: Testing Locally and Deployment
- Performance Metrics
- Conclusions
This work is under a Attribution-NonCommercial-NoDerivatives 4.0 International license.
Comments
There are no comments yet.