HOW IT WORKS / BUILT WITH DJUST

A real-time multiplayer game.
Zero lines of app JavaScript.

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.

0
lines of app JavaScript
3,011
lines of Python
440
lines of CSS
10/s
game ticks per room
1,024
board cells, patched in place
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 the server's event loop.

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)(
        game, room
    )

if changed:
    try:
        await apush_to_view(
            VIEW_PATH, handler="handle_refresh_room", payload={"room": 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. Each session in the room 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, and sessions in other rooms ignore it.

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_to_view(VIEW_PATH, handler="handle_refresh_room", payload={"room": self.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

A fixed board where only classes change.

The board is 1,024 div elements that never move. A frame changes only their class names, so djust's Rust VDOM diff sends a small list of class patches, not new HTML. The grid is built once per version and shared by every viewer in the room.

snake_arena/templates/snake_arena/index.htmlhtml+django
{% for row in rows %}{% for c in row %}<div class="cell {{ c }}"></div>{% endfor %}{% endfor %}
snake_arena/game.pypython
def rows(self):

    with self.lock:
        cached = self._rows_cache
        if cached is not None and cached[0] == self.version:
            return cached[1]
        grid = self._build_rows()
        self._rows_cache = (self.version, grid)
        return grid

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><buttondj-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 reaches every room

push_to_view targets a view class, not a room, so each room's broadcast also reaches players in every other room, who discard it. That's fine at this scale. A room-scoped push in djust would remove the waste.

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. Running more workers would need shared room state and one clock per room across them.

Dropped pushes

djust drops a server push that arrives while a session is busy with an event. During play the next frame repairs the screen. Nothing follows a match's final frame, so the clock sends it again a second later.

Build your own with djust.

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

Or go play a round →