HOW IT WORKS / BUILT WITH DJUST

A real-time multiplayer game.
One small canvas script.

Snake Arena is a Django app built on djust, a LiveView-style framework. The simulation, bots, scoring and rendering all run on the server in Python. The browser holds an open WebSocket and applies the small DOM patches djust sends it; one script draws the board.

408
lines of app JavaScript
3,732
lines of Python
337
lines of CSS
10/s
game ticks per room
1,024
board cells, one string per frame
0
build steps

ARCHITECTURE

One room, many tabs, one clock.

Snake Arena architectureThree browser tabs each hold a WebSocket to their own djust LiveView session on the server. One room clock, an asyncio task, ticks the room's in-memory SnakeGame ten times a second and pushes a refresh to the sessions whenever the game changes. Each session then re-reads the room, re-renders, and sends DOM patches back to its tab.BROWSER TABSLIVEVIEW SESSIONSTHE ROOMPlayer 1Player 2SpectatorsessionsessionsessionRoom clockasyncio taskSnakeGameone per roomin memoryWebSocketevents ⟶ · ⟵ patchestick · 10/spush on changeeach session re-reads the room, re-renders, and patches its tab

01 / THE GAME LOOP

One clock per room, on one of the server's event loops.

djust can run a server-side tick for every LiveView session, but a game needs one clock per room, not one per open tab. So the first tab to connect to a room starts an asyncio task for it. Ten times a second it reads who is present and advances the game in a worker thread, so game logic never blocks WebSocket traffic, and pushes a refresh when anything changed. It stops about five seconds after the room empties, or up to a minute later if a tab died without closing cleanly and its presence has to expire.

The sessions themselves tick only every five seconds, to keep their presence alive and restart the room's clock if it has stopped while they're still there.

snake_arena/clock.pypython
def step(game, roster):
…
    before = game.version
    if roster:
        game.prune(roster)
    game.tick()
    return game.version != before
snake_arena/clock.pypython
try:
    changed, roster = await sync_to_async(
        _advance, thread_sensitive=False, executor=_EXECUTOR
    )(game, room)
…
if changed:
    try:
        …
        await apush_to_view(
            VIEW_PATH,
            handler="handle_refresh_room",
            payload={"room": room},
            scope=room,
        )
snake_arena/views.pypython
presence_key = "snake:{room}"
# Each browser tab is its own presence → its own player seat.
presence_unique_per_connection = True
…
tick_interval = 5000

02 / SERVER PUSH

Each change is broadcast, and each viewer re-renders.

The game itself is a plain Python object, one SnakeGame per room, held in memory. Anything render-worthy bumps its version number. The clock, or a handler acting on a player's click, then calls push_to_view with scope set to the room. Each session set push_scope to its room when it mounted, so only that room's sessions receive the push. Each runs handle_refresh_room, re-reads the room and re-renders its own template. The session whose click caused the change has already re-rendered, so djust skips its copy.

The refresh handler has no @event_handler decorator on purpose. Server push may call handle_* methods, but djust's strict event security refuses to let a browser call anything undecorated.

snake_arena/views.pypython
def _publish_if_changed(self, game, before):
…
    if game.version == before:
        self._skip_render = True
        return
    self._load()
    push_room(self.room)
snake_arena/views.pypython
def push_room(room):
…
    push_to_view(VIEW_PATH, handler="handle_refresh_room", payload={"room": room}, scope=room)
snake_arena/views.pypython
self.room = room
# Scoped push: this session receives push_room(room) broadcasts only.
self.push_scope = room
snake_arena/views.pypython
def handle_refresh_room(self, room=None, **kwargs):
    if room is not None and room != self.room:
        self._skip_render = True  # broadcast for another room
        return
    self._load()

03 / RENDERING

One canvas, drawn from one string.

The board is a single canvas. The server still builds the 32×32 grid once per version and shares it with every viewer in the room, then packs it into one short string (runs of empty cells, one character per wall or pickup, three per snake cell). djust patches that one attribute, and a small board script redraws the canvas from it, so a frame costs one string diff instead of 1,024 elements.

snake_arena/templates/snake_arena/index.htmlhtml+django
<canvas id="snake-board" dj-hook="SnakeBoard" data-board="{{ board }}"
snake_arena/game.pypython
def board(self):
…
    with self.lock:
        cached = self._board_cache
        if cached is not None and cached[0] == self.version:
            return cached[1]
        code = encode_board(self.rows())
        self._board_cache = (self.version, code)
        return code

04 / INPUT

Keyboard and touch controls, declared in the template.

Arrow keys and WASD are bound with djust's dj-shortcut directive, and :prevent stops the page scrolling. The arrows also carry dj-shortcut-in-input, so they steer even while the name field or the volume slider has focus; the WASD letters don't, so typing a name never steers. The touch pad is plain buttons with dj-click. A key press only records the new direction and never re-renders; the room clock's next beat does.

snake_arena/templates/snake_arena/index.htmlhtml+django
<div class="board-stage" dj-shortcut="arrowup:key:prevent,arrowdown:key:prevent,arrowleft:key:prevent,arrowright:key:prevent" dj-shortcut-in-input>
…
<button …dj-click="pad" dj-value-dir="up"
snake_arena/views.pypython
@event_handler()
def key(self, key: str = "", **kwargs):
    direction = KEYMAP.get(key)
    if direction:
        get_game(self.room).set_dir(self._uid(), direction)
    # Input never re-renders; the tick does.
    self._skip_render = True

05 / SEATS AND PRESENCE

Each tab is its own player, seated while seats last.

PresenceMixin tracks who is in each room. With one presence per connection, every browser tab counts as a separate player: it takes a free seat, or spectates once the table is full. A seat is claimed only on the WebSocket mount, never on the first HTTP render, and handle_presence_leave frees it when the tab goes away.

snake_arena/views.pypython
if hasattr(self, "_websocket_session_id"):
    game = get_game(room)
…
    self.track_presence(meta={"role": self.my_role})
    ensure_clock(room)
snake_arena/views.pypython
def handle_presence_leave(self, presence):
    """Free my seat on disconnect; tell the room."""
…
            game.release(uid)

06 / SOUND

Sound effects, with no audio code in the app.

AudioMixin declares the sound bank, and the {% djust_audio %} tag renders the player controls. The room keeps one shared log of sound events. Each session remembers how far it has played, so a reconnect never replays old sounds.

snake_arena/views.pypython
audio_banks = {"snake": SoundBank({
    name: Sound(f"snake/audio/{name}.wav", volume=0.55)
…
}, max_voices=4)}
snake_arena/views.pypython
if events:
    self.play_sounds("snake", events[-32:])

TRADE-OFFS

What djust doesn't do yet, and how the game works around it.

Push is scoped by hand

A room-scoped push needs two halves to agree: the view sets push_scope to its room, and every push passes scope=room. A push that forgot the scope would reach every session in every room, so the handlers go through one helper, push_room, the clock passes the same scope, and a test fails on any push without one. Presence joins and leaves are scoped the same way, to the sessions sharing the room's presence key (djust does this itself from #3095). Until djust 1.3 push_to_view could only reach the whole view, and every room's broadcast woke the sessions in every other room.

The room clock is app code

djust's tick runs per session, and it has no scheduler for state that sessions share, so the game runs its own small asyncio task per room (clock.py).

One process

Rooms and their clocks live in memory in a single server process. On free-threaded Python, with djust's worker_threads pool, that one process renders on several cores at once, and djust serve runs it on two event loops that share the rooms. Running more processes would need shared room state and one clock per room across them.

Dropped pushes

Before djust 1.2.1, a server push that arrived while a session was busy with an event was dropped. djust now queues it and applies it when the event finishes, but nothing follows a match's final frame, so the clock still sends it again a second later as a backstop.

Build your own with djust.

Server-rendered, real-time Django. No API layer, no build step, no frontend framework.

Or go play a round →