# Off-Device Development

This SDK doesn’t ship an in-editor simulator widget — Godot’s run-in-editor flow plus a mouse fallback is the equivalent. The patterns below show how to develop and iterate without a connected Board.

The SDK is designed to be a clean no-op when the BoardSDK Android plugin isn’t loaded — every method silently returns a default. You build desktop development on top of that by writing fallback paths that synthesize fake input from mouse and keyboard.

This guide covers the off-device workflow: how to keep your game running inside the Godot editor, how to detect when you’re off-device, and how to wire keyboard/mouse fallback that lets you exercise game logic without flashing to hardware every iteration.

## The `Board.is_on_device` Flag

Every SDK call is gated by `Board.is_on_device`. The flag is true only when the Android plugin singleton is registered with the Godot engine — meaning you’re running on the actual Board hardware (or a sideloaded build on another Android device with BoardOS).

```
func _ready() -> void:
    if not Board.is_on_device:
        # Off-device: editor, desktop export, anywhere without BoardSDK.aar.
        _setup_desktop_fallback()
        return
    # On-device: real Board input is available.
    Board.initialize("00000000-0000-0000-0000-000000000000")
    Board.input.activate("models/arcade_v1.3.7.tflite")
    Board.input.subscribe()
```

There’s no bridge-version number to check, and you don’t need one. The compiled AAR ships together with the GDScript SDK as a single package, so your game already knows exactly which SDK it bundled. The only version skew that matters — this SDK versus the OS services it talks to at runtime — is handled inside the native plugin, which checks each OS service’s capability before calling newer methods and degrades gracefully on older OS. So there’s nothing to feature-gate from game code.

> **Note**: The SDK is safe to leave unguarded — every module method already checks `Engine.has_singleton("BoardSDK")` internally and short-circuits. The `if not Board.is_on_device: return` pattern is for _your_ code’s fallback paths, not to prevent crashes.

## Why There’s No Editor Simulator

A few reasons the SDK doesn’t ship an in-editor simulator:

1. **GDScript can’t link the native AAR off-device.** The AAR is Android-only. No matter how cleverly you stub it, the editor doesn’t get the real Piece-recognition model or the OS-level services it talks to.
2. **The editor doesn’t reproduce the device.** Even with a perfect Piece simulator, your game would behave subtly differently on real hardware — different refresh rate, different touch latency, different GPU. The fix is to deploy early and often, not to simulate harder.
3. **Game-side fallback is more flexible.** A simulator that lives in _your game_ knows your game’s coordinate system, your Piece set, and your level layout. A generic SDK-side simulator can’t.

The recommended pattern is: keep editor runs working for layout, scene wiring, dialogue, and UI work. Export to an APK and install on a Board to test anything touch-sensitive.

## A Minimal Off-Device Guard

The simplest possible scene supports both modes:

```
extends Node

const APP_ID := "00000000-0000-0000-0000-000000000000"
const PIECE_SET_MODEL := "models/arcade_v1.3.7.tflite"

func _ready() -> void:
    if not Board.is_on_device:
        push_warning("[main] running off-device — Board APIs are no-ops")
        return
    Board.initialize(APP_ID)
    Board.input.contacts_received.connect(_on_contacts)
    Board.input.activate(PIECE_SET_MODEL)
    Board.input.subscribe()

func _on_contacts(contacts: Array) -> void:
    for c in contacts:
        print("contact: glyph=%d at (%.0f, %.0f)" % [c.glyph_id, c.x, c.y])
```

Running this in the editor: you get a clean log message, no crashes, no contacts. The scene loads, nodes resolve, your other game logic runs. Running on-device: contacts arrive normally.

## Synthesizing Fake Contacts from the Mouse

A more useful pattern is to synthesize fake contact Dictionaries from mouse input when off-device. This lets you exercise the same `_on_contacts` handler regardless of platform. `contacts_received` always carries raw `Dictionary` entries (the 60 fps hot path stays untyped to avoid per-frame allocation), so the fakes you build below are plain Dictionaries too.

> **Tip**: For typed access in non-hot-path code you can wrap a single entry with `BoardContact.from_dict(c)`, which exposes the `BoardContact.Type` (`FINGER` / `GLYPH` / `BLOB`) and `BoardContact.Phase` (`NONE` / `BEGAN` / `MOVED` / `ENDED` / `CANCELED` / `STATIONARY`) enums plus helpers like `facing() -> Vector2` and `is_piece()`. Don’t wrap inside the per-frame loop itself. The `Board.input.TYPE_*` / `Board.input.PHASE_*` int constants still exist (same values) for reading a raw Dictionary’s `type_id` / `phase_id`.

```
extends Node

const PIECE_SIMULATED := 12  # any Piece your game expects

var _fake_contact_id := 0
var _active_fake: Dictionary = {}

func _ready() -> void:
    if Board.is_on_device:
        Board.initialize("00000000-0000-0000-0000-000000000000")
        Board.input.contacts_received.connect(_on_contacts)
        Board.input.activate("models/arcade_v1.3.7.tflite")
        Board.input.subscribe()
    else:
        # Off-device: wire mouse input as fake contacts.
        set_process_unhandled_input(true)

func _on_contacts(contacts: Array) -> void:
    # Same handler whether contacts come from BoardSDK or the mouse fallback.
    for c in contacts:
        _update_visualization(c)

func _unhandled_input(event: InputEvent) -> void:
    if Board.is_on_device:
        return

if event is InputEventMouseButton:
        if event.button_index == MOUSE_BUTTON_LEFT:
            if event.pressed:
                _fake_contact_id += 1
                _active_fake = _make_fake_contact(
                    _fake_contact_id, event.position, PIECE_SIMULATED)
                _on_contacts([_active_fake])
            else:
                _active_fake = {}
                _on_contacts([])
    elif event is InputEventMouseMotion and not _active_fake.is_empty():
        _active_fake.x = event.position.x
        _active_fake.y = event.position.y
        _active_fake.phase_id = Board.input.PHASE_MOVED
        _on_contacts([_active_fake])

func _make_fake_contact(id: int, pos: Vector2, glyph: int) -> Dictionary:
    return {
        "contact_id": id,
        "x": pos.x,
        "y": pos.y,
        "orientation": 0.0,
        # glyph_id 0 == finger, 1+ == a piece type.
        "glyph_id": glyph,
        "phase_id": Board.input.PHASE_BEGAN,
        "is_touched": true,
        "frame_number": Engine.get_process_frames(),
    }
```

Now `_on_contacts` runs the same code path in both environments — click-and-drag in the editor produces the same Dictionary your handler would receive from a real Piece.

## Rotation via Mouse Wheel

To exercise orientation-sensitive code, layer mouse wheel input on top of the fake contact:

```
func _unhandled_input(event: InputEvent) -> void:
    if Board.is_on_device:
        return
    if _active_fake.is_empty():
        return

if event is InputEventMouseButton:
        if event.button_index == MOUSE_BUTTON_WHEEL_UP and event.pressed:
            _active_fake.orientation = fmod(
                float(_active_fake.orientation) + 5.0, 360.0)
            _on_contacts([_active_fake])
        elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN and event.pressed:
            _active_fake.orientation = fmod(
                float(_active_fake.orientation) - 5.0, 360.0)
            _on_contacts([_active_fake])
```

Combined with the click-to-place pattern above, you have:

- Left click to place a Piece
- Drag to slide
- Wheel to rotate
- Release to lift

That covers most of what a real player would do with a Piece.

## Piece Picker via Keyboard

The mouse only lets you simulate one Piece at a time. Wire keyboard keys to swap between Piece IDs:

```
const PIECE_KEYS := {
    KEY_1: 12,
    KEY_2: 27,
    KEY_3: 45,
    KEY_0: 0,  # 0 = finger
}

var _current_glyph := 12

func _unhandled_input(event: InputEvent) -> void:
    if Board.is_on_device:
        return
    if event is InputEventKey and event.pressed and not event.echo:
        if PIECE_KEYS.has(event.keycode):
            _current_glyph = PIECE_KEYS[event.keycode]
            print("[sim] simulating glyph=%d" % _current_glyph)
```

Then use `_current_glyph` instead of the constant `PIECE_SIMULATED` in `_make_fake_contact`. Pressing `1`, `2`, `3` swaps the active Piece; `0` simulates a finger. (Recall the rule: dispatch by `glyph_id`, where `0` is a finger and `1+` is a piece type.)

## Multi-Contact Simulation

If your game needs to test multi-Piece interactions (a Pawn on one tile while a player taps an Action Piece, for example), keep a Dictionary of fake contacts keyed by `contact_id` and emit the whole set every frame:

```
var _fakes: Dictionary = {}  # contact_id -> Dictionary

func _physics_process(_dt: float) -> void:
    if Board.is_on_device:
        return
    if _fakes.is_empty():
        return
    # Emit all active fakes — mirrors how the SDK accumulates contacts.
    _on_contacts(_fakes.values())
```

Press `Q` to spawn a fake at the current mouse position; `W` to delete the nearest fake; `E` to clear all. Key each fake by `contact_id` so two Pieces sharing a `glyph_id` stay distinct across frames. The pattern scales to whatever ergonomics you need.

## Disabling UI Controls When Off-Device

The sample app’s `sidebar.gd` walks the scene tree and disables any interactive control when off-device — buttons, sliders, line edits, check boxes. The pattern is general:

```
func _ready() -> void:
    _build_ui()
    if not Board.is_on_device:
        _disable_interactive_controls(self)

func _disable_interactive_controls(node: Node) -> void:
    for child in node.get_children():
        if child is Button or child is OptionButton or child is HSlider \
                or child is LineEdit or child is CheckBox:
            child.disabled = true
        _disable_interactive_controls(child)
```

This way the editor still renders your UI correctly — useful for catching layout regressions — but a misclick doesn’t trigger a Board API call that would only no-op anyway. The UI signals to the user that interactive controls are dormant.

You can also pair this with a banner that explicitly tells viewers what’s happening:

```
if not Board.is_on_device:
    var banner := Label.new()
    banner.text = "(off-device — install on a Board to use the SDK)"
    banner.modulate = Color(0.6, 0.65, 0.75)
    add_child(banner)
```

## Avatars and Save Games Off-Device

`Board.avatar.await_load_avatar()` returns `null` off-device (it returns a ready-to-display `ImageTexture` on hardware, cached after first load). `Board.save.await_list()` returns an empty `Array[BoardSaveMetadata]`. Your code should handle the empty/null case as a normal outcome:

```
var saves: Array[BoardSaveMetadata] = await Board.save.await_list()
if saves.is_empty():
    if Board.is_on_device:
        push_warning("no saves yet — first run")
    else:
        push_warning("off-device — saves not available")
```

For UI prototyping where you want fake data, branch on `Board.is_on_device` and return canned data off-device. Each entry is a typed `BoardSaveMetadata` (fields: `id`, `description`, `created_at`, `updated_at`, `played_time`, `file_size`, `game_version`, `player_count`; plus an `is_valid()` helper). Drive your UI off the typed fields:

```
func get_saves_for_ui() -> Array[BoardSaveMetadata]:
    if Board.is_on_device:
        return await Board.save.await_list()
    return [\
        BoardSaveMetadata.new("mock-1", "Morning game", 1024, "0.1.0"),\
        BoardSaveMetadata.new("mock-2", "Tournament run", 4096, "0.2.0"),\
    ]
```

Read fields off the typed object directly — `meta.id`, `meta.description`, `meta.file_size`, `meta.game_version`, and so on. A single result from `Board.save.await_create()` is a `BoardSaveMetadata` or `null`, so guard it with `if meta == null` rather than `is_empty()`. The `save_created` / `save_listed` signals carry the same typed `BoardSaveMetadata`.

The pattern: synthesize the data your game would have at runtime, drive the UI from it, and let real data take over on hardware.

`Board.save.get_app_storage_info()` is synchronous and returns `{}` off-device. On hardware it returns a Dictionary with `total_storage`, `used_storage`, `remaining_storage` (all `int` bytes) and `usage_percentage` (`float`, 0.0–1.0) — use it to render a storage meter or pre-flight whether a save will fit. Off-device, branch on the empty result the same way and return canned figures if you want to lay out the meter:

```
func get_storage_for_ui() -> Dictionary:
    var info: Dictionary = Board.save.get_app_storage_info()
    if not info.is_empty():
        return info
    # Off-device: canned numbers so the meter has something to render.
    return {
        "total_storage": 64 * 1024 * 1024,
        "used_storage": 16 * 1024 * 1024,
        "remaining_storage": 48 * 1024 * 1024,
        "usage_percentage": 0.25,
    }
```

## Pause Menu Off-Device

The system pause menu is OS-level. Off-device, calls to `Board.pause.set_context()` are no-ops, and the `pause_result_received` signal never fires. The context you pass to `set_context()` is caller-supplied input and stays a plain `Dictionary` (including its custom-button and audio-track entries). What changed is the _result_ coming back: `pause_result_received(result: BoardPauseResult)` and `Board.pause.poll_result()` return a typed `BoardPauseResult` (fields: `action`, `custom_button_id`, `audio_tracks`; helper `is_present()`). The `Board.pause.ACTION_*` / `Board.pause.ICON_*` constants are unchanged. If you need to test pause-flow logic in the editor, fake the signal:

```
func _unhandled_input(event: InputEvent) -> void:
    if Board.is_on_device:
        return
    if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
        # Fake a "user clicked Quit" pause result.
        _on_pause_result(BoardPauseResult.new(Board.pause.ACTION_QUIT))

func _on_pause_result(result: BoardPauseResult) -> void:
    if not result.is_present():
        return
    match result.action:
        Board.pause.ACTION_QUIT:
            Board.application.quit()
        Board.pause.ACTION_RESUME:
            pass
```

Off-device, `Board.application.quit()` falls through to `get_tree().quit()`, so the editor closes cleanly — that part of the lifecycle is exercised end-to-end.

## Recommended Workflow

A tight desktop-then-device loop:

1. **Build features in the editor.** Get the UI and game logic running with mock data and synthesized contacts. You can iterate in seconds.
2. **Wire SDK calls behind `if Board.is_on_device`.** They no-op off-device, so the editor still runs.
3. **Check in once per logical unit of work.** Commit when the desktop build is in a known-good state; that way each device deploy starts from a clean baseline.
4. **Deploy to hardware for any touch-sensitive change.** The simulator can lie to you about timing, smoothing, and inertia. Real hardware is the only source of truth for “does this feel right?”
5. **Use `print()` liberally on first device run.** Stream the running log over Board Connect — `board-connect logs <package>` (agents), or tail it from the Board Connect web UI (humans). Anything that’s wrong on hardware but not in the editor usually surfaces in the first 30 seconds.

## Limitations of the Off-Device Fallback

What desktop fallback _won’t_ tell you:

- **Real touch latency.** Mouse input has near-zero latency; the touch sensor has ~50ms.
- **Real tracking failures.** Pieces can momentarily drop out when moved fast or covered; the mouse doesn’t.
- **Hand presence (`is_touched`).** The mouse has no equivalent — you’d need to keybind it manually.
- **Multi-finger gestures.** A single mouse pointer can only simulate one Contact at a time without bookkeeping.
- **Render performance.** Desktop GPUs are 5-50x faster than the Genio 700 in the Board. A scene that runs at 60fps on your laptop may hit 25fps on hardware.

For any of these, build a real APK and install it on a Board. There’s no build script in the supported path — export the APK with Godot’s own exporter:

- **In the editor:** _Project → Export Project… → Android_.
- **Headless / CI:**`godot --headless --export-debug "Android" build/game.apk`.

With `gradle_build/use_gradle_build=true` in the Android export preset, the export runs the Gradle build, bundles the addon’s `board.aar` (so `lib/arm64-v8a/libboard.so` is present for the install gate) and the Piece Set Model into the PCK, and signs with the debug keystore. The model loads from the PCK, so a vanilla export Just Works — no asset staging. The Board’s install gate requires the APK to contain `lib/arm64-v8a/libboard.so`, or the install is refused with “not built with Board SDK”; the bundled `board.aar` satisfies this automatically.

One-time setup: after _Project → Install Android Build Template_, add `noCompress "pck", "sparsepck"` to the `aaptOptions` block in `android/build/build.gradle`, right after the `ignoreAssetsPattern` line. Godot’s stock template doesn’t set this; without it the PCK is deflated inside the APK and cold boot takes ~2 minutes instead of a few seconds.

Then install the APK on a Board:

- **From a browser (human):** open the Board Connect web UI (the Board shows its address under _Settings → System_) and drag the `.apk` onto it, then launch from the Library.
- **From an agent:** use the `board-connect` CLI (installable from [dev.board.fun/connect/install](https://dev.board.fun/connect/install)) — `board-connect pair <host>` once (tap **Approve** on the device), then `board-connect install <apk>`, `board-connect launch <package>`, `board-connect logs <package>`, and `board-connect screenshot --out shot.png` to confirm behavior. No cable, ADB, or scripts required.
