Drive a 360° petri-dish arena with DashRCJoystick — floor tilesets composed into data-URI panoramas, steered through the 0.2.0 imperative camera

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:

  1. 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.

  1. 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.

  1. 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 loopPackage answer
Steer the camera from a joystick0.2.0 lookAt={pitch, yaw, hfov, animated} — imperative, no rebuild
Keep zoom across moves0.2.0: hfov is reported back like pitch/yaw
Move without ANY flash or reset0.3.0: panoramaCanvasId scene + dynamicUpdate — redraw pixels, never rebuild
No white blink on the rebuilds you DO keep0.3.0: viewer container is transparent (was Pannellum's #f4f4f4)
Instant tour jumps elsewhere0.2.0 preloadScenes (default on) + hideLoadingSpinner
Real-time markers0.2.0: callbackHotspots applies via addHotSpot/removeHotSpot
Scene switch without rebuild0.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: