Running multiplayer game loop with django channels

Hello everyone.

It is easy to find information on how to implement a chat server or a turn base multiplayer game with Django channels, but it’s much harder to find on how to create real-time multiplayer game, like a websocket game.

By real time I mean that, let’s say, there is an infinite loop that runs 30 times per second, receives controls from the players, and based on that, updates the current state of the game – physics and and such, and sends it to the players on every tick. Let’s say, a simple pong game, for example. Multiple players should play matches of this game at the same time.

What would be an approach for this type of game? From my understanding, it’s not good to run this kind of infinite loop from within the consumer, as it will block the entire process. I am a little lost right now, so any directions would be appreciated.

Welcome @emuminov !

There are a couple different ways to handle this.

Personally, I’m a fan of using multiple processes.

I do this type of thing in my board game engine in that my games implement a time limit for most actions.

I rely heavily upon Channels Worker processes, all communicating through the channels layer.

Each game title manages all the instances of a game. (For example, if I had chess as a game, there would be one Worker running all the chess games being played.

The workers are all single-threaded synchronous code such that received messages are processed in the strict order in which they are received, and are processed to completion before the processing of the next message is started.

In addition to those workers, there’s a separate Timer Worker that, when given a game instance, message, and a duration, sends that message back to the Game Worker requesting it at the expiration of the duration. (The Game Worker can also send a Cancel message to the Timer Worker to cancel a particular timer.)

From the perspective of the Game Worker, then the Timer Worker message works much in the same manner as any other channel messages being sent to it by a consumer - it’s an indication that the Game needs to somehow change its state and send appropriate messages back out to the consumers.

In my case, when a game enters a state where a person has a limited amount of time to make a choice, the Game Worker sends the update to the player, then sends the message to the Timer Worker to start a timer.

If the player responds before the timer message is received, then everything’s good. If the timer message is received before the player has acted, then the game takes the predetermined action based upon the game state.

In your case, you could change the concept slightly such that the Timer Worker could be programmed to send a message at periodic intervals (e.g. 33 ms) to the Game Worker. The Game Worker then uses that input to change the state.

Side note: While testing, you can use a single instance of runworker command to run all the workers within a single Daphne instance. However, in a deployment environment, I run a separate Daphne instance for each Worker in addition to the Daphne instance handling the consumers. This is to ensure that no individual game can interfere with the processing of the timer or other game title.

1 Like

Hey @KenWhitesell ! Thank you for detailed answer. This is definitely an interesting approach. What I have thought about is to create an independently running game loop for each of the instances of the game (ie if there are 2 1v1 pong games, there are two game loops), and running them as an async task.

I considered using background tasks, but from my understanding they are still consumers, and thus (I assumed) aren’t suitable for running infinite loops in the handlers. And I am not sure if it’s possible to run “normal” python program as a worker.

The idea with the Timer Worker is also interesting. I assume it monitors a list of game instances and sends events to them based on time in an infinite loop? I thought that doing that will block handling of other events (such as Cancel message that you described), because the worker is busy with the loop. Does the handler for the Cancel message still runs even if another handler is busy with the loop?

Offtopic, also was very pleasantly surprised when I saw that it was you who posted on this question haha. I watched your recorded talks at django con and regularly saw your answers and discussions on different topics on the forum. Feels nice to touch the legend.

Don’t think of them as “loops”.

In an asynchronous environment, you don’t “loop” or “sleep”.

An event happens - user input, timer tick, whatever. You react to that event, doing everything you need to do at that time. Then your function returns, passing control back to the event loop.

Yes, they are “consumers” in the sense that they consume channel_layer messages. However, they don’t directly connect to browsers via websockets. See Worker and Background Tasks — Channels 4.2.0 documentation for more details.

A “SyncConsumer” is as normal as it gets.

Again - it doesn’t loop. It sets a timer and returns control to the event loop.
When the timer expires, the appropriate method is called. It does what it needs to do and then returns.

It also doesn’t “monitor” anything. The game instance sends a message to the timer worker to start a timer. In your case, the timer worker then would send messages periodically to a game worker until it’s told to stop.

Lose the concept of “looping” here. Your code should be written to react to events.

You might want to take a slight step back to focus on your understanding of asynchronous processing. Once you twist your head around some of the concepts, you’ll find your whole perspective into this issue will be different.

I appreciate the thought, but the real legends are the hundreds of people who make Django the amazing framework that it is. I do what I do here simply to allow the truly skilled, talented, and energetic people who move Django forward to not need to spend their time here answering questions.

1 Like

Lose the concept of “looping” here. Your code should be written to react to events.

Understood. Thank you for helping me to understand async.

It also doesn’t “monitor” anything. The game instance sends a message to the timer worker to start a timer. In your case, the timer worker then would send messages periodically to a game worker until it’s told to stop.

So if I understood you correctly, you offload everything to multiple processes.

  • The “main” process, which is a django server with django channels, which accepts inputs and sends them to the game worker, starts and stops the timer worker, and sends the calculated by the game worker state back to clients.
  • The game worker, which manages game instances, performs the game calculations and sends the state to the “main” process.
  • The timer worker, which manages ticks for each of the game instances and sends timer events to the workers who are responsible for them.

I assume that the example code below which I wrote is very roughly similar in idea, with the exception of course that it’s everything tucked into a single consumer. Direct calls to the timer and game_tick, as well as some of the attributes, can be replaced with the self.channel_layer.send call with an appropriate type and data.

This example simply sends an incrementing number to the client every second.

class GameConsumer(AsyncWebsocketConsumer):
    # ... connection, disconnection logic etc ...

    async def match_command(self, event):
        """Handles user input."""
        action = event["action"]
        match action:
            case "start":
                if not self.should_run:
                    self.should_run = True
                    asyncio.create_task(self.timer())
            case "stop":
                self.should_run = False

    async def state_update(self, event):
        """Propagates the state to the players."""
        await self.send(text_data=json.dumps({"message": event["state"]}))

    def calculate_timer_time(self):
        """Calculates timer time to target X ticks per second."""
        return 1

    async def timer(self):
        """Sends events to the game at a certain interval."""
        while self.should_run:
            await asyncio.sleep(self.calculate_sleeping_time())
            await self.game_tick()

    def update_the_state(self):
        """Handles the game logic."""
        self.state += 1

    async def game_tick(self):
        self.update_the_state()
        await self.channel_layer.group_send(
            self.match_group_name,
            {"type": "state_update", "state": self.state},
        )

I appreciate the thought, but the real legends are the hundreds of people who make Django the amazing framework that it is.

Your work is very appreciated too. I started my programming journey with Python and Django and since then I regularly saw your helpful solutions.

Minor points:

Yes, this is absolutely correct.

There is no “main” process. It’s not like any one process controls or manages other processes. They’re all “peers” within the environment.

The processes can be defined (in this context) by where it receives input from and where it delivers output.

Using that definition, the processes then are:

  • Websocket consumer (async)

    • Input: Websockets, channel_layer
    • Output: Websocket, channel_layer
  • Game worker(s) (sync)

    • Input: Channel_layer
    • Output: Channel_layer
  • Timer worker (async)

    • Input: Channel_layer, timer events
    • Output: Channel_layer

(While I believe we’re on the same page, I want to reduce the chances of confusion. We may be saying the same thing different ways, but I’d like to try to make sure that’s the case here.)

One of the benefits of splitting these functions into separate processes is that it minimizes (if not effectively eliminates) the possibility of a game worker from interfering with either the websocket consumer or the timer worker. (You want to avoid having game updates from preventing a timer signal from being triggered at the proper time.)

1 Like