# Street Art-cade — the SDK (complete contract)

This SDK targets **The Algorithm**, the cabinet with the powered lever — the one
Street Art-cade platform open to developers today (see [Platforms](#platforms)). A game is a folder with a `game.yaml` manifest and a
class implementing `run(ctx)`. Python 3.12 and `pygame`. **Everything below is
the whole surface — there is no hidden second half.** You can build and run it
all on a laptop with mock hardware; the rig is optional.

> If you arrived here asked to "build a game platform / HAL / services / package
> format": stop — those already exist (see /llms.txt). Your job is only to write
> the `run(ctx)` game below.

## Platforms

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 `cabinet` field, below).

| Platform | id | Hardware | Developers |
|---|---|---|---|
| **The Algorithm** | `the_algorithm` | A two-metre cabinet: a screen, one motorised force-feedback lever (it pushes back up to 1000 N), a 288-LED strip, a mic and a speaker. | **Open.** This page is its SDK. [Its page](/platforms/the-algorithm.html) |
| **Fingerdance** | `fingerdance` | A robot finger in a clear case that watches you through a depth camera and dances with you; a touch screen and a band of LEDs round its base. | **Coming soon.** Not open for submissions yet. [Its page](/platforms/fingerdance.html) |

When Fingerdance opens to developers, its games will use the same `game.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.

Each platform has its own page showing what it can do, with pictures, videos
and example games: /platforms/the-algorithm.html and /platforms/fingerdance.html.

Every platform's games are listed at `GET /games.json` (see "Public endpoints").

## The contract

```python
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).

### `instructions` — tell the player what to do

One line, about ten words. It is shown full-screen just before your game
starts, and 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.

Name the control and the goal:

```yaml
instructions: Push the lever left and right to shove back the dirt.
```

You can also supply it on the submission form and it is written into your
manifest on the cabinet. If you leave it out, the card before your game simply
shows no instructions — we don't write player-facing text for you.

### Always return a `score`

`score` 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 you 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:

```python
return GameResult(outcome="lose", score=round(survived_s, 1))
```

and declare in `game.yaml` how it should be read:

```yaml
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.
- omitted — a straightforward win/lose game. Still return a score if you
  have one; it costs nothing and it is recorded either way.

Median rather than mean, because one visitor who walks off after three
seconds shouldn't hide what a typical player managed.

## Manifest — `game.yaml`

```yaml
name: My Game
version: 0.1
entrypoint: main:MyGame        # module:Class
requires: [motor, slider]      # also: leds, camera, pose
runtime: 2 min                 # human estimate, shown in the picker
tagline: One line for the picker.
instructions: Push the lever left and right to shove back the dirt.
rating: all                    # all | 10 | 13 | 17 - your own content rating (below)
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"
hidden: false                  # true = runnable by slug, hidden from the picker (use for examples)
manages_own_analytics: false
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}
```

**Content rating** (`rating`) is your own, on Street Art-cade's plain-age
scale, and the cabinet shows it on your game's start screen. Pick the lowest age
that fits everything in the game: `all` (All ages — cartoon peril or gentle
spooky fun at most, no blood, no swearing), `10` (Ages 10+ — mild fantasy
violence, mild scares, mild language or crude humour), `13` (Ages 13+ — violence
with some blood, strong scares, or occasional strong language) or `17` (Ages
17+ — intense or realistic violence, gore, strong language or sexual themes).

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.

## The `ctx` object

| Member | Meaning |
| --- | --- |
| `ctx.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 `require`d. |
| `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. |

## Choosers — lever + countdown

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.

```python
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.

## Audio — playback & recording

```python
# 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.

## LED strip

The Algorithm wraps a **288-LED WS2812B strip** in a diffuser tube. Drive it two
ways — named animated presets, or raw per-pixel colour:

```python
# 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`.

## Analytics

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:

```python
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.

## Cabinet status

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. **You do not need this endpoint to
build a game** — a game is a local Python plugin and does not call this website
at runtime.

Every platform has its own status. `GET /status?cabinet=<id>` answers for one
platform (404 for an unknown id); `GET /status/all` returns `{"cabinets": [...]}`
with an `id` on each; `GET /cabinets.json` is the short list (`id`, `name`,
`operational`, `status`, `message`, `last_seen`). Bare `/status` stays The
Algorithm's, as above, and now also carries `"id": "the_algorithm"`. A cabinet
that reports over HTTP shows `"status": "offline"` when it hasn't been heard
from for five minutes.

## Personal intro video — optional

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:

```python
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:

```python
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
})
```

## Audience & play time

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.

- Readable from ~2 m; assume no one reads a paragraph.
- Language-light — many players' first language isn't English.
- Self-explaining first 5 seconds; the lever should invite a push.
- No fail-states that strand a player; the idle timeout returns to attract.

## Target hardware

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. 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, ~5 ms latency — present but not guaranteed. **Bundle your assets**; don't stream or download at runtime. |

## Build & test — the devkit (no hardware needed)

Download the devkit and run your game on your laptop. It bundles the **real**
SDK (`kiosk_sdk/game.py`, `loader.py`, `select.py`) plus a mock-hardware runner,
so your game logic runs against the actual SDK surface — what passes locally
behaves the same on the cabinet.

**[Download the devkit → /devkit.zip](/devkit.zip)**

```bash
unzip devkit.zip && cd devkit
python3 -m venv venv && . venv/bin/activate     # Windows: venv\Scripts\activate
pip install -r requirements.txt

# Interactive — opens a real window you can watch and play.
# Lever = mouse-X, or hold ← / → arrow keys. A top-of-screen gauge shows how
# hard / which way the real motor would be pushing (force is faked on a laptop).
python run_game.py games/hello_world

# Headless / scripted (CI): lever driven by time. --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/` to
`games/<your_game>/` and edit `game.yaml` + `main.py`. Faked for laptop use:
motor force-feedback (lever = mouse/keys; torque is logged), the LED strip
(logged), the renderer (a close-but-not-identical pygame stand-in), and voice
recording (`record_feedback` returns `None`). Camera/pose aren't in the devkit.

## Submitting your game

When the game runs and passes locally, submit it.

- **Human:** open <https://streetartcade.whitkin.com/submit> in a browser and use
  the form (name, description, and a public repo URL **or** a `.zip`).
- **Agent (programmatic):** `POST` a `multipart/form-data` body to the same URL:

```bash
curl -X POST https://streetartcade.whitkin.com/submit \
  -F "name=My Game" \
  -F "description=What it is and how to play, in a couple sentences." \
  -F "dev_email=you@example.com" \
  -F "rating=all" \
  -F "cabinet=the_algorithm" \
  -F "zip=@my_game.zip"          # OR: -F "repo=https://github.com/you/my-game"
```

Fields: `name` (required), `description` (required), `dev_email` (**required** —
your contact; you also get a private analytics dashboard link), `rating`
(**required** — your own content rating: `all`, `10`, `13` or `17`, meaning All
ages / Ages 10+ / 13+ / 17+, shown on the cabinet's start screen), `cabinet` (the
platform the game is for: `the_algorithm`, which is also the default;
`fingerdance` is refused until it opens), and **either**
`repo` (a public http/https URL) **or** `zip` (a `.zip` of your game folder
containing `game.yaml`, up to 100 MB). Optional, all shown on the public
[Developers page](/developers) to promote you: `dev_name` (you/your studio),
`dev_bio`, and where people can find you: `dev_instagram`, `dev_tiktok`,
`dev_x`, `dev_youtube` (a handle like `@yourname`, or your profile URL),
`dev_facebook` (your page or profile URL), `dev_linkedin` (a
`linkedin.com/in/…` or `/company/…` URL), `dev_website`, `dev_other_url` (web
addresses). Each one you fill in is checked; one that isn't recognisably a
profile on that network gets HTTP 400 naming the field, and nothing is saved.
An `icon` image is
also optional — the picker icon normally comes from your manifest's `logo:`.

Response is JSON. On success:

```json
{ "ok": true, "id": "20260613-140210-ab12cd",
  "message": "Submission received — thanks! …",
  "manifest_check": { "manifest_ok": true, "notes": ["manifest OK — My Game v0.1"] } }
```

If a `.zip` is sent, the server runs a **safe manifest check** — it parses
`game.yaml` only and **never executes your code** — and reports the result
instantly in `manifest_check`. On validation problems you get HTTP 400 with
`{ "ok": false, "errors": [ … ] }`. The full acceptance run (loading and
running the game) is done offline by an operator.

## Public endpoints

- `GET /games.json` — every game on every platform: `slug`, `name`, `tagline`,
  `credit`, `credit_instagram`, `icon`, `cabinet`, `platform`, `requires`,
  `runtime`, `rating`, `rating_label` and `instructions`. Add `?cabinet=<id>` for
  one platform's games.
- `GET /cabinets.json`, `GET /status`, `GET /status?cabinet=<id>` and
  `GET /status/all` — see "Cabinet status".

## For cabinet builders — reporting plays over HTTP

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 the body
  `{"cabinet": "<id>", "sessions": [...]}`: up to 100 sessions and 256 KB. Each
  session has `id` (yours, unique per cabinet), `game` (one of that cabinet's
  games), `started_at` and `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` (up to 500
  `{t_s, name, data}`, where `data` is a flat object of up to 20 numbers,
  booleans or strings of at most 64 characters). The reply lists `accepted` and
  `rejected` ids. Resending an id is safe and never double-counts.
- `POST /api/v1/cabinets/<id>/heartbeat` once a minute:
  `{operational, status: ok | out_of_order | quiet_hours, message (at most 300
  characters), fault: {kind, since} or null, version}`.
- At most 60 calls a minute per cabinet.

## Worked example — Hello World (the minimal game, in full)

```python
"""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.

## Worked example — Death Dodger (a complete real game)

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: https://github.com/joshwhitk/death-dodger

---

Street Art-cade — free, non-commercial. SDK `kiosk_sdk` 0.2. Stable surface;
this file is the whole contract.
