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. Theif not Board.is_on_device: returnpattern 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:
- 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.
- 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.
- 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 theBoardContact.Type(FINGER/GLYPH/BLOB) andBoardContact.Phase(NONE/BEGAN/MOVED/ENDED/CANCELED/STATIONARY) enums plus helpers likefacing() -> Vector2andis_piece(). Don’t wrap inside the per-frame loop itself. TheBoard.input.TYPE_*/Board.input.PHASE_*int constants still exist (same values) for reading a raw Dictionary’stype_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:
- Build features in the editor. Get the UI and game logic running with mock data and synthesized contacts. You can iterate in seconds.
- Wire SDK calls behind
if Board.is_on_device. They no-op off-device, so the editor still runs. - 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.
- 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?”
- 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
.apkonto it, then launch from the Library. - From an agent: use the
board-connectCLI (installable from dev.board.fun/connect/install) —board-connect pair <host>once (tap Approve on the device), thenboard-connect install <apk>,board-connect launch <package>,board-connect logs <package>, andboard-connect screenshot --out shot.pngto confirm behavior. No cable, ADB, or scripts required.
See Also
- Touch Input — the SDK side of contact handling
- Performance — what will change between desktop and device
- Piece Interaction Design — design considerations the simulator can’t test