¿He creado un motor de videjuegos para Django?

TL;DR: Juego multiplayer completo en el navegador formado por 270 líneas de Python y 0 de JavaScript funcionando sobre Django gracias a Django LiveView.

Después de mi aventura en Doom, me hice una pregunta. ¿Podría construir un sencillo juego multijugador en Django? Similar a trabajar con herramientas como Pygame, salvo que el resultado se dibujaría en una página web. Django LiveView podría evitarme tener que escribir JavaScript y gestionar los WebSockets. Todo el estado del juego viviría en el servidor y se envía HTML renderizado a los clientes via WebSockets. ¡Juguemos!

Mi objetivo era crear el juego de Snake, pero multiplayer, con varios jugadores compitiendo en el mismo tablero. Cada jugador controla su propia serpiente, tratando de comer comida y evitar chocar con otras serpientes o las paredes. El juego vivirá en un espacio de 20x20 celdas, o 400 divs, y se actualiza 10 veces por segundo. Lo que es un total de 4000 divs/segundo. Bastante fácil para un navegador moderno.

Paso 1: Game Loop en Background Thread

Todo juego que se precie tiene un game loop que actualiza el estado del juego y renderiza la salida. En este caso, el loop se ejecuta en un hilo separado para no bloquear el servidor web principal:

def loop():
    """Main game loop"""
    while True:
        sleep(0.1)
        update()  # Actualiza posiciones, detecta colisiones
        render()  # Broadcast HTML a todos los clientes

def start():
    thread = threading.Thread(target=loop, daemon=True)
    thread.start()

Paso 2: Broadcast a todos los clientes

La magia está en enviar la cuadrícula HTML renderizada a todos los clientes conectados. En concreto se actualizaría 10 veces por segundo (10 FPS) ya que el juego no necesita más fluidez.

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

Paso 3: Capturar eventos del teclado

Con Django LiveView, los eventos del teclado se pueden manejar directamente en Python. El mapeo del teclado usa las teclas WASD para el movimiento:

<section data-liveview-keyboard-map='{"w":"key_up","a":"key_left","s":"key_down","d":"key_right"}'
         data-liveview-focus="true">

O puedes añadir un botón:

<button data-liveview-function="key_up" data-action="click->page#run">☝️</button>

En ambos casos, el evento se maneja en 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")

Técnicamente hablando, quien captura los eventos es JavaScript (no hay otra manera), en concreto Stimulus o el LiveView JS, pero todo el manejo de eventos y la lógica del juego se hace en Python. Django LiveView trabaja con atributos HTML y decoradores en Python. Sería similar a usar htmx salvo que aquí no hay una API REST de por medio y todo el tiempo se usa una única conexión de WebSockets.

Paso 4: Identificación de Jugadores

Ya nos metemos en la parte de la lógica. ¿Cómo identificamos a cada jugador? Toda página tiene un UUID único generado por LiveView accesible via la plantilla:

{% load liveview %}
<html lang="en" data-room="{% liveview_room_uuid %}">

Que además es accesible en Python ya que se asigna al consumidor. El juego utiliza un estado global compartido accedido por todos los 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()

Una solución elegante e integrada con el framework. Cada jugador es identificado por su room_id único, y el juego automáticamente gestiona la limpieza de jugadores.

Paso 5: La lógica del juego - Función Update

El corazón del juego es la función update(), llamada 10 veces por segundo por el game loop. Maneja el movimiento, colisiones, consumo de comida, y limpieza de jugadores inactivos:

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()]

Observa cómo los jugadores inactivos son automáticamente eliminados después de 30 segundos sin actividad. Esto previene que el juego se llene de jugadores desconectados.

Paso 6: Renderizado dinámico con estilos en línea

Cada celda del canvas se representa con un color diferente, dependiendo de si es parte del cuerpo de un jugador, comida, o espacio vacío. El truco está en usar estilos en línea para definir el color de fondo y borde de cada celda directamente en el HTML renderizado.

<div style="background-color: {{ col.color }}; border-color: {{ col.color }};"></div>

La plantilla de mi canvas completo se ve así:

{% 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 %}

Paso 7: Probando en local y despliegue

El primer paso es probar si funciona en mi local, contra mi mismo. Levanto 2 instancias de mi navegador y empiezo a jugar contra mi mismo.

Parece que funciona bien. Sin embargo es una situación poco realista.

  • Mi equipo es potente.
  • No tengo latencia.
  • Solo hay 2 jugadores.

El siguiente paso es desplegarlo en una máquina humilde, una Raspberry Pi 3, y jugar con varios amigos cada uno desde su casa. Uno de ellos estaba en un continente a más de 9.700 km de distancia.

Uno de ellos jugó desde un móvil con conexión 4G.

Y a pesar de todo ¡funcionó! El juego era perfectamente jugable.

Métricas de rendimiento

Hablemos de números. Cada broadcast envía toda la cuadrícula de 20x20 (400 celdas) como HTML a todos los clientes conectados:

Tamaño del payload por actualización:

  • Celda vacía: ~47 bytes (<div class="canvas__cell canvas__floor"></div>)
  • Celda de jugador: ~125 bytes (incluye estilos en línea para el color)
  • Celda de comida: ~48 bytes

Con un tablero mayormente vacío, cada actualización envía aproximadamente 18-20 KB de HTML. A 10 FPS, eso son 180-200 KB/s por cliente para actualizaciones broadcast.

Conclusiones

Me quedan más dudas que certezas, me hubiera encantado jugar con 10 o 20 jugadores simultáneos para ver cómo se comporta el servidor. Pero en general, estoy muy satisfecho con el resultado. Los navegadores son solo un espejo del estado del juego que vive en el servidor, su único trabajo es renderizar y enviar los nuevos eventos. Ello hace que no solo sea fácil de mantener sino también escalable. Puedo añadir nuevas características al juego sin tener que tocar el código de los handlers WebSocket o preocuparme por sincronizar el estado entre cliente y servidor.

Para mí los aciertos de usar este enfoque son:

  • No necesito escribir JavaScript.
  • El estado del juego está centralizado en el servidor, y es individual por jugador.
  • Solo tengo que jugar con las plantillas Django.
  • Están automatizadas las conexiones WebSocket y la sincronización del DOM.

Pero no todo son ventajas:

  • No hay client-side prediction o interpolación, lo cual puede ser un problema para juegos que requieran baja latencia.
  • Broadcasting HTML es más pesado que enviar JSON con datos.
  • Un mal rendimiento del servidor afecta a todos los jugadores.

Mi intención no es crear juegos con Django LiveView, sino experimentar capacidades realtime. Es una buena herramienta para añadir interactividad sin complicaciones. Y por lo visto, también puede con juegos multiplayer sencillos.

  • Demo online: Podéis usar W D S A para controlar la serpiente, pero previamente debes pulsar un botón de dirección. Y tendrás que adivinar qué colores eres ¡lo siento!.
  • Código fuente

Este trabajo está bajo una licencia Attribution-NonCommercial-NoDerivatives 4.0 International.

¿Me invitas a un café?

Comentarios

Todavía no hay ningún comentario.

Escrito por Andros Fenollosa

enero 16, 2026

7 min de lectura

Sigue leyendo

Visitantes en tiempo real

Estás solo: 🐱