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.

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) — 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.

See Also