The API
This SDK targets The Algorithm, the cabinet with the
powered lever — the one Street Art-cade platform open to developers today
(see Platforms). A game is a folder under
games/ with a game.yaml manifest and a class
implementing run(ctx). Python 3.12 and pygame.
Everything below is the whole surface — there isn't a hidden second half.
You can build and run it all on a laptop with mock hardware; the rig is
optional.
Street Art-cade games run on its sculptures, and each sculpture is its own platform with its own hardware. A game is built for one of them, and you say which when you submit.
the_algorithmfingerdancegame.yaml with
cabinet: fingerdance, and its developer API will be
published here. A game will never get the raw camera image or control
of the robot finger.
What it can do, and a video of it dancing ›Every platform's games are listed at
GET /games.json (add ?cabinet=<id> for
one platform).
from kiosk_sdk import Game, GameContext, GameResult
class MyGame:
name = "My Game"
version = "0.1"
def run(self, ctx: GameContext) -> GameResult:
while not ctx.should_quit():
ctx.tick(60) # ~60 fps; returns dt seconds
x = ctx.slider # lever: 0.0 left .. 1.0 right
ctx.screen.fill((10, 10, 12))
ctx.renderer.draw_centered_text(f"{x:.2f}", "xl")
ctx.flip()
if x > 0.95:
return GameResult(outcome="win", score=x)
return GameResult(outcome="abandon")
GameResult(outcome, score=None, message="", extra={}) —
outcome is "win" | "lose" | "abandon" | "skip".
extra is a free dict logged to analytics (and carries a
couple of launcher flags, below).
The cabinet's chooser shows a 256×256 square PNG
(inside a circle — keep the corners empty) with a ~15-word
line under it. Set them in game.yaml as
logo: / tagline:, or upload them on the
submission form — form uploads are installed on the
cabinet automatically and override the manifest. Both are shown again on
the card before your game starts.
instructions — tell the player what to doOne line, about ten words, shown full-screen just before your game starts. On the cabinet it is the only place a player is told anything: they walk up cold, mid-conversation, with a motorised lever as the entire input device — no keyboard, no buttons, no touch. A game that starts without this spends its first thirty seconds being worked out rather than played.
instructions: Push the lever left and right to shove back the dirt.
You can also supply it on the submission form and we’ll write it into your manifest. If you leave it out, the card before your game simply shows no instructions — we don’t write player-facing text for you.
scorescore is recorded as a first-class column on the session
and is what your dashboard reports. Not every good game has a
win state. A survival game — the grave fills, the house always
wins — is 100 % “lose” by design, and a dashboard
showing a flat 0 % win rate tells you nothing about whether anyone
is enjoying it. The number that matters is how long they lasted.
So return a real score on every path, including the losing one:
return GameResult(outcome="lose", score=round(survived_s, 1))
and declare in game.yaml how it should be read:
scoring: survival # "" (win/lose, default) | survival | points
score_label: Survived # names the axis on your dashboard
score_unit: s # appended to the number
scoring: survival — no win state; the score is
the result. Your dashboard headlines best / median / recent scores and
footnotes the outcome breakdown.scoring: points — winnable, but almost nobody does, so
how far they got is the more useful report.Median rather than mean, because one visitor who walks off after three seconds shouldn’t hide what a typical player managed.
game.yamlname: My Game
version: 0.1
entrypoint: main:MyGame # module:Class
requires: [motor, slider] # also: leds, camera, pose
instructions: Push the lever left and right to shove back the dirt.
rating: all # all | 10 | 13 | 17 - your own content rating, shown on the start screen
cabinet: the_algorithm # which platform the game is for (see Platforms)
scoring: "" # "" | survival | points (see "Always return a score")
score_label: Score # e.g. "Survived", "Gates cleared"
score_unit: "" # e.g. "s"
runtime: 2 min # human estimate, shown in the picker
tagline: One line for the picker. # ~15 words; also the pre-game card's description
logo: chooser.png # 256x256 square PNG, centred (shown in a circle)
author: Your Name · @handle # shown to players on the pre-game card ("by …"); socials/contact OK
launcher_card: true # pre-game card: tile + tagline + instructions + Start/Cancel
card_tagline: true # repeat the tagline above the instructions on that card
instruction_icons: # up to two small pictures, one each side of the instructions
- {image: art/spirit.png, label: spirit}
hidden: false # true = runnable by slug, hidden from the picker (use for examples)
manages_own_analytics: false
Loaded with clear validation: a missing key, a malformed
entrypoint, bad YAML, or an unknown requires
entry each fail (or warn) with a message naming the file. Publishing is
just dropping the folder in — it appears in the picker automatically.
ctx objectctx.slider | Lever position, 0.0 (left) – 1.0 (right). |
|---|---|
ctx.limits | MotorLimits(cw, ccw) — lever pinned at the right / left stop. |
ctx.hw.motor | fights.drive_torque(pct) (±100), slider_value. The lever resists in proportion to the torque you command. |
ctx.hw.leds | 288-LED strip — pattern(name), set_all(r,g,b), set_pixel(i,r,g,b), off() (below). |
ctx.screen | pygame.Surface backbuffer; ctx.W, ctx.H. |
ctx.renderer | Shared text / slider drawing helpers. |
ctx.camera / ctx.pose | Depth/RGB frame and pose tracker — present only if required. |
ctx.tick(fps) / ctx.flip() / ctx.should_quit() | Frame loop. |
ctx.settings | Your manifest's settings: block. |
ctx.select(...) | In-game chooser (below). |
ctx.play_sound / stop_sound / play_video / record_feedback | Audio & video (below). |
ctx.event(name, **data) | Analytics (below). |
ctx.session_id | Current analytics session id — a stable key for per-play persistence. |
The platform's only input is the lever, so the SDK gives you a lever-driven chooser with hold-to-commit and an idle countdown — the same UX as the cabinet's game picker, reusable inside your game for difficulty, mode, replay, etc.
mode = ctx.select(["Gentle", "Normal", "Brutal"], prompt="Difficulty")
again = ctx.select([("Play again", True), ("Done", False)], timeout=20, hold=2)
The lever picks a zone; holding it still fills a countdown bar and
commits. Returns the chosen value, or None if the player
walked off. On the idle timeout it commits the hovered
option so the cabinet never stalls.
# Playback (non-blocking; wav/ogg, mp3 where SDL supports it)
ctx.play_sound("assets/hit.wav", volume=0.8)
ctx.play_sound("assets/drone.ogg", loops=-1) # loop forever
ctx.stop_sound()
# Recording — the cabinet's voice-comment screen; returns a WAV path or None
wav = ctx.record_feedback(prompt="Say the name on your tombstone")
Recording uses the installation's own voice screen (count-up timer,
lever to save / cancel, idle auto-save) and stores the WAV alongside the
session; transcription is handled for you. The audio directory is
configurable (KIOSK_AUDIO_DIR) so it works on a dev box.
The Algorithm wraps a 288-LED WS2812B strip in a diffuser tube. Drive it two ways — named animated presets, or raw per-pixel colour:
# Named animated presets (run on the strip's own controller)
ctx.hw.leds.pattern("typical") # calm ambient — the resting look
ctx.hw.leds.pattern("chaos") # fast, hot, full-hue (use during intensity)
ctx.hw.leds.pattern("user_victory")
# Raw colour — solid, or individual pixels (i = 0..287)
ctx.hw.leds.set_all(10, 80, 255) # whole strip one colour (RGB 0–255)
ctx.hw.leds.set_pixel(i, 255, 40, 0)
ctx.hw.leds.off()
Presets: typical, chaos,
fight_mild, fight_hard, voices,
tug_war, user_victory, meta_victory,
beat_algo, standback, off. Map them
to your game's beats, or paint the strip yourself with
set_all/set_pixel.
The platform opens a session per play, samples the lever at ~10 Hz with the motor torque, and records the outcome, duration and any rating. You add your own events:
ctx.event("level_cleared", level=3, score=42) # auto-tagged with slug + version
Events, slider traces and outcomes show up on the operator dashboard.
If your game subprocesses its own thing and writes its own session rows,
set manages_own_analytics: true to avoid double-counting;
otherwise leave it off and the platform counts the play for you.
The cabinet's site exposes a public health endpoint at
/status. When the cabinet is faulted (e.g. a broken belt or
an offline motor), operational is false, and
the site shows an "out of order" banner:
GET /status
{
"cabinet": "The Algorithm",
"operational": false,
"status": "out_of_order",
"message": "Out of order — belt repair in progress.",
"fault": { "kind": "belt_broken", "since": "…" }
}
Derived from the kiosk's fault flag. When it trips, the cabinet screen shows the banner itself and operators get a phone push.
Every platform has its own status: GET /status?cabinet=<id>
(404 for an unknown id), GET /status/all for all of them, and
GET /cabinets.json for the short list (id,
name, operational, status,
message, last_seen). Bare /status
stays The Algorithm's, as above. A cabinet that reports over HTTP shows
"status": "offline" when it hasn't been heard from for five
minutes.
A cabinet that isn't synced to the site (Fingerdance) reports its plays and its health over HTTPS, with a per-cabinet token the operator issues. Numbers and short strings only: no audio, video, images or visitor text, and unknown fields are refused.
POST /api/v1/sessions with Authorization: Bearer <token>
and {"cabinet": "<id>", "sessions": [...]}: up to 100
sessions and 256 KB. Each session has id,
game (one of that cabinet's games), started_at,
ended_at (ISO 8601 with a time zone) and outcome
(win, lose, abandon, skip or completed), and optionally
game_version, duration_s, score and
events. The reply lists accepted and
rejected ids; resending an id never double-counts.POST /api/v1/cabinets/<id>/heartbeat once a minute:
operational, status (ok, out_of_order or
quiet_hours), message, fault,
version.A game may ship a short personal clip (an artist intro, or a “please rate” card) that the launcher plays around the feedback step. It is entirely optional — most games ship none. To play one yourself at the exact moment you want:
ctx.play_video("please-rate.mp4") # fullscreen, blocking
If you run your own intro/feedback inside the game and don't want the launcher's, return:
return GameResult(outcome="win", extra={
"skip_launcher_video": True, # don't play the launcher's pre-feedback clip
"skip_launcher_feedback": True, # don't run the launcher's voice recording
})
Cabinets live in public space. Players are mostly Australian and Asian college students, often in groups, walking up cold with no instructions. Typical sittings run ~1–3 minutes; design for a complete experience under five.
Build against the actual cabinet PC. It's modest — target 60 fps 2D, not heavy 3D.
| Machine | Dell Inspiron 3537 (laptop, lid-closed) |
|---|---|
| CPU | Intel Core i7-4500U — 2 cores / 4 threads @ 1.8 GHz (Haswell) |
| RAM | 16 GB |
| GPU | Intel HD 4400 integrated — the active display GPU. (A discrete AMD R9 M265X exists but the kiosk renders on the iGPU.) Target modest 2D; no heavy shaders. |
| Display | 1280 × 720 @ 60 Hz, HDMI. Draw inside a safe area — the bezel crops ~10% sides / 7.5% top / 15% bottom. |
| OS / session | Ubuntu 24.04 LTS, X11 (not Wayland) |
| Runtime | Python 3.12, pygame-ce 2.5 |
| Network | ~35 Mbit/s down/up, ~5 ms latency — a venue link: present but not guaranteed. Bundle your assets; don't stream or download at runtime. |
Grab the devkit and build on your laptop, no
hardware needed. It bundles the real SDK
(game.py, loader.py, select.py) with
a mock-hardware runner, so your game logic runs against the actual SDK
surface — what passes locally behaves the same on the cabinet.
unzip devkit.zip && cd devkit
python3 -m venv venv && . venv/bin/activate
pip install -r requirements.txt
# Interactive — opens a real window you can watch and play. Lever = mouse-X
# or ← / → keys; a top gauge shows how hard the motor would push (faked).
python run_game.py games/hello_world
# Headless / scripted (CI): --script win | lose | stalemate
python run_game.py games/hello_world --headless --script win
The devkit ships the hello_world template —
copy games/hello_world/ and go. Faked for laptop use: motor
force-feedback (lever = mouse/keys), LEDs, the renderer, and voice
recording; camera/pose aren't included.
"""Hello World — a minimal kiosk_sdk game."""
from kiosk_sdk import Game, GameContext, GameResult
class HelloWorld(Game):
name = "Hello World"
version = "0.1"
def run(self, ctx: GameContext) -> GameResult:
win_thr = ctx.settings.get("win_threshold", 0.95)
abandon_thr = ctx.settings.get("abandon_threshold", 0.05)
while not ctx.should_quit():
ctx.tick(60)
slider = ctx.slider
ctx.screen.fill((10, 10, 20))
ctx.renderer.draw_centered_text(
f"slider = {slider:.2f}", "xl", y_off=-40)
ctx.renderer.draw_centered_text(
"push RIGHT to win · push LEFT to quit", "md", y_off=40)
ctx.renderer.draw_ancestor_slider(slider, is_suppressed=False, t=0.0)
ctx.renderer.draw_slider_action_labels(
left="Quit", right="Win",
left_color=(220, 100, 100), right_color=(100, 220, 140))
ctx.flip()
if slider >= win_thr:
return GameResult(outcome="win", score=slider,
message="You won Hello World")
if slider <= abandon_thr:
return GameResult(outcome="abandon", message="User pushed left")
return GameResult(outcome="abandon", message="quit requested")
Manifest is three lines — name,
version, entrypoint: main:HelloWorld. It ships
with the platform as a runnable template (hidden: true —
usable by slug, kept out of the picker). Copy it and start.
A full game that runs in public: motor torque that scales with the player's age, persistent tombstones from past players, voice feedback, and an optional clip. Full source: github.com/joshwhitk/death-dodger.