Joystick Arena
Drive a 360° petri-dish arena with DashRCJoystick — floor tilesets composed into data-URI panoramas, steered through the 0.2.0 imperative camera
What this is
A limited, focused lab: one DashPannellum viewer, one DashRCJoystick (from dash-gauge), and a petri-dish floor tileset — the same {z}/{x}/{y} rasters a dash-leaflet2 map serves flat — turned into a place you can stand inside and glide around. No game mechanics, no entities: just the locomotion loop, because that's the part that stresses the component.
It exists to answer one question from the 360 Dish Lab prototyping sessions: what does dash-pannellum need so a tileset arena feels real-time rather than turn-based? The answer became 0.2.0.
Live example
Tilt the stick to look around — that's the lookAt prop, an imperative camera write with no viewer rebuild. Pin the stick to its rim to glide: the floor re-projects into the scene's live canvas texture (0.3.0's panoramaCanvasId + dynamicUpdate) on a steady cadence — the viewer itself is never rebuilt, so there is no flash, no camera reset, nothing to bake. The segmented control jumps between the three published arena sizes (3×3 @ z15, 5×5 @ z9, 7×7 @ z3 — native-zoom blocks of the pip-install-python petri tileset).
# File: docs/arena/arena_example.py
import dash_gauge as dg
import dash_mantine_components as dmc
from dash import Input, Output, clientside_callback, dcc, html
from dash_pannellum import DashPannellum
component = dmc.Stack(
[
html.Div(
DashPannellum(
id="arena-pano",
width="100%",
height="100%",
autoLoad=True,
compass=True,
hideLoadingSpinner=True,
# 0.3.0 dynamic-canvas mode: the scene is bound to a <canvas>
# (panoramaCanvasId) and this keeps the texture refreshing —
# movement is just redrawing pixels, never a rebuild.
dynamicUpdate=True,
),
style={
"width": "100%",
"aspectRatio": "2 / 1",
"borderRadius": "8px",
"overflow": "hidden",
"background": "#04080f",
},
),
dmc.Group(
[
dg.DashRCJoystick(
id="arena-joy",
directionCountMode="Nine",
baseRadius=70,
controllerRadius=32,
throttle=110,
),
dmc.Stack(
[
dmc.SegmentedControl(
id="arena-size",
value="early",
data=[
{"value": "early", "label": "3×3 · early"},
{"value": "mid", "label": "5×5 · mid"},
{"value": "late", "label": "7×7 · late"},
],
size="xs",
),
dmc.Code("loading arena…", id="arena-hud"),
dmc.Text(
"Tilt the stick to look (imperative lookAt). Push it to "
"the rim to glide: each step redraws the floor tiles "
"into the scene's live canvas texture — the viewer is "
"never rebuilt, so there is nothing to flash.",
size="xs",
c="dimmed",
),
],
gap="xs",
style={"flex": 1, "minWidth": 260},
),
],
align="center",
gap="xl",
wrap="wrap",
),
dcc.Interval(id="arena-boot", interval=400, max_intervals=1),
],
gap="md",
)
# First paint once the page (and assets/arena360.js) are up.
clientside_callback(
"""async function(n) { return await window.ARENA360.start(); }""",
Output("arena-hud", "children"),
Input("arena-boot", "n_intervals"),
prevent_initial_call=True,
)
# Growth-stage switch: a different native-zoom tile block, same arena.
clientside_callback(
"""async function(stage) { return await window.ARENA360.start(stage); }""",
Output("arena-hud", "children", allow_duplicate=True),
Input("arena-size", "value"),
prevent_initial_call=True,
)
# The joystick: every frame steers the gaze, a full push glides.
clientside_callback(
"""async function(angle, distance) {
return await window.ARENA360.drive(angle, distance);
}""",
Output("arena-hud", "children", allow_duplicate=True),
Input("arena-joy", "angle"),
Input("arena-joy", "distance"),
prevent_initial_call=True,
)
:defaultExpanded: false :withExpandedButton: true
How the tileset becomes a panorama
assets/arena360.js does the projection, entirely client-side:
- Mosaic — the N×N native-zoom tiles are drawn into one offscreen
canvas (the floor texture). 3×3, 5×5 and 7×7 blocks are vendored under assets/tilesets/dish/{z}/{x}/{y}.jpg.
- Floor projection — for every output pixel below the horizon, the
eye-ray hits the floor at ground distance g = eye / tan(−pitch) along the pixel's bearing; sample the mosaic at that world point. Distance fog and an out-of-mosaic haze finish the illusion. Above the horizon: a dark condenser-sky gradient.
- Live canvas texture — the scene is bound ONCE to a hidden 1792×896
<canvas> via the scene key panoramaCanvasId; with dynamicUpdate=True the sphere re-reads it every frame (Pannellum's dynamic mode — the same machinery as 360° video). Movement is putImageData (~60 ms), full stop. No HTTP, no data URIs, no tour re-outputs, no rebuild.
The bearing contract matches the flat map: yaw 0° = north = "up" on the tileset, clockwise — so a dash-leaflet2 minimap and this 360 view can share one heading.
The tours pattern (scene-switch hotspots) is a teleport mechanic — great for room-to-room tours, wrong for an arena. Here hotspots are demoted to optional transition pieces; locomotion is continuous: look with lookAt, glide by re-composing the floor. If you want contacts/markers, callbackHotspots is now live in 0.2.0 (diffed imperatively, no rebuild), so entities could drift in real time.
What 0.2.0 + 0.3.0 added to make this work
| Need in the arena loop | Package answer |
|---|---|
| Steer the camera from a joystick | 0.2.0 lookAt={pitch, yaw, hfov, animated} — imperative, no rebuild |
| Keep zoom across moves | 0.2.0: hfov is reported back like pitch/yaw |
| Move without ANY flash or reset | 0.3.0: panoramaCanvasId scene + dynamicUpdate — redraw pixels, never rebuild |
| No white blink on the rebuilds you DO keep | 0.3.0: viewer container is transparent (was Pannellum's #f4f4f4) |
| Instant tour jumps elsewhere | 0.2.0 preloadScenes (default on) + hideLoadingSpinner |
| Real-time markers | 0.2.0: callbackHotspots applies via addHotSpot/removeHotSpot |
| Scene switch without rebuild | 0.2.0 loadScene="scene-id" |
Still on the wishlist for a later release: hotspots + viewer-config passthrough in multiRes mode, and a bundled (non-CDN) Pannellum runtime.
The movement-mechanics design, distilled
Three tiers, by how often the world changes:
LOOK (every frame) lookAt prop / drag imperative, free
MOVE (game cadence) redraw the panoramaCanvasId pixels only, ~60ms,
canvas (dynamicUpdate=True) no rebuild, no flash
WORLD (rare) re-output tour / loadScene rebuild or scene jump —
(+ preloadScenes, spinner off, cover it with a fade,
transparent container) it's a real transition
One interaction lesson from building it: DashRCJoystick only emits on change, so a held stick goes silent — hold-to-glide needs its own cadence loop (setInterval while pinned, cleared on release). Everything runs in window.ARENA360 + three small clientside_callbacks — the server is never in the locomotion loop. A server callback only belongs here when game state does (eating, spawning, scoring), which is exactly where the turn-based 360 Dish Lab pattern picks up.
Source: /components/arena
Note for AI agents: This is the static, prerendered view of an interactive Dash application served because we detected a non-JS user agent. Full prose docs:
- /components/arena/llms.txt — LLM-friendly documentation
- /sitemap.xml
- /robots.txt