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.
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.
def step(game, roster):
…
before = game.version
if roster:
game.prune(roster)
game.tick()
return game.version != beforetry:
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}
)presence_key = "snake:{room}"
# Each browser tab is its own presence → its own player seat.
presence_unique_per_connection = True
…
tick_interval = 500002 / 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.
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})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.
{% for row in rows %}{% for c in row %}<div class="cell {{ c }}"></div>{% endfor %}{% endfor %}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 grid04 / 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.
<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"@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 = True05 / 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.
if hasattr(self, "_websocket_session_id"):
game = get_game(room)
…
self.track_presence(meta={"role": self.my_role})
ensure_clock(room)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.
audio_banks = {"snake": SoundBank({
name: Sound(f"snake/audio/{name}.wav", volume=0.55)
…
}, max_voices=4)}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.