AI Assistant

This page does two things. First, it tells you how to set up Claude, Cursor, GitHub Copilot, or any other AI coding assistant so it can write correct, idiomatic Board Godot SDK code. Second, the Quick Reference section below IS the payload you can paste directly into your tool of choice as a system prompt, .cursorrules file, or top-of-file comment block. Copy-paste-and-go.

Note: Everything here is optional. The SDK works the same with or without AI assistance. If you’d rather hand-roll, the API Reference and the FAQ have every signature and gotcha you’ll need.

Setup

Claude (Claude Code, Claude Desktop)

Claude automatically reads any CLAUDE.md files in or above the directory it’s operating in. The SDK ships one at addons/board_sdk/CLAUDE.md — once the addon is installed in your project, Claude picks it up for free with no extra configuration. To prime Claude at the project root as well, copy that file (or its contents) into a CLAUDE.md at your repo root. For brand-new projects, install the bootstrap-board-godot-game Claude Code skill once with cp -R skills/bootstrap-board-godot-game ~/.claude/skills/ — after that, open Claude Code and run /bootstrap-board-godot-game to scaffold a complete Board game from a few prompts.

Cursor

Cursor reads .cursorrules at your project root and injects it into every chat. Copy the entire Quick Reference section below into .cursorrules, commit it to source control, and the whole team gets the same context. Cursor also respects CLAUDE.md-style files if you have AI Composer configured for multi-file context, so the addon’s addons/board_sdk/CLAUDE.md works there too.

GitHub Copilot and others

Copilot doesn’t have a project-level rules file like the others. The next-best thing is a top-of-file comment block in your main script (main.gd or whichever file you spend the most time in) that contains the Quick Reference below — Copilot reads the surrounding file context aggressively and will pick up the conventions from there. For ChatGPT, Aider, or any tool with a system-prompt slot, paste the Quick Reference in directly.


Quick Reference

(This is the section to copy into your AI tool. Everything below this line is written as a self-contained briefing for an AI assistant — humans can read it too, but the framing assumes the reader is about to write code against the SDK.)

You are writing GDScript against the Board Godot SDK (board-godot-sdk). Board is a 23.8” 1080p landscape touch console; its touch sensor detects both fingers and physical Pieces (game tokens with conductive Glyph patterns on their bases).

GDScript basics

  • Board is a global autoload registered by the addon. No using, import, or extends needed — just call Board.input.subscribe() etc. from any script.
  • Always guard SDK calls with if not Board.is_on_device: return at the top of any function that depends on the native plugin. Off-device (editor, desktop), every method either no-ops or returns a typed default ([], {}, -1, false).
  • The SDK is in a single autoload script — no scene-tree wiring, no nodes to instantiate.
  • Method names are snake_case. load_data is named to avoid shadowing GDScript’s built-in load(). There is no direct delete (matching the Unity SDK); detach players with remove_players_from_save / remove_active_profile_from_save and the OS deletes the save once none remain.

Touch input

Subscribe to Board.input.contacts_received, activate a Piece Set model .tflite, and filter contacts by your game’s known glyph_id values.

func _ready() -> void:
    if not Board.is_on_device:
        return
    Board.initialize("00000000-0000-0000-0000-000000000000")
    Board.input.contacts_received.connect(_on_contacts)
    Board.input.activate("models/your_piece_set.tflite")
    Board.input.subscribe()

func _on_contacts(contacts: Array) -> void:
    for c in contacts:
        if c.glyph_id == GLYPH_PAWN:  # game-side constants, not SDK helpers
            _move_pawn(c.contact_id, c.x, c.y)

Contact Dictionary shape (every contact has these keys):

Key Type Notes
contact_id int Unique per physical contact. Stable across frames.
x, y float Display-pixel position. Y-down, origin top-left.
orientation float Degrees, screen-CW positive. Only meaningful for Glyphs.
type_id int 0 finger, 1 glyph.
phase_id int 1 BEGAN, 2 MOVED, 3 ENDED, 4 CANCELED, 5 STATIONARY.
glyph_id int 0 for finger; 1+ for Piece type. Maps to game-side names.
is_touched bool true while the Piece is being physically held.
frame_number int Kernel frame for cross-layer correlation.

Tip: The per-frame contacts_received payload is a deliberately untyped Array of raw Dictionaries (the 60 fps hot path). For one-off typed access you can wrap a single entry with BoardContact.from_dict(c) — that gives you BoardContact.Type (FINGER, GLYPH, BLOB), BoardContact.Phase (NONE, BEGAN, MOVED, ENDED, CANCELED, STATIONARY), facing() -> Vector2, and is_piece(). Don’t convert the whole per-frame loop; the raw c.get(...) reads below are the intended fast path, and Board.input.TYPE_* / PHASE_* int constants (same values) are there for raw-dict reads.

Warning: Filter and dispatch contacts by glyph_idglyph_id 0 is a finger and 1+ identifies a Piece type. But to track a specific Piece instance across frames, key on contact_id, because two identical Pieces (e.g. two Pawns) share the same glyph_id. Using glyph_id as the per-instance tracking key collapses all instances of one Piece type into a single entry — every game with duplicate Pieces will be broken until this is fixed.

Tracking Pieces across frames and detecting edges

contacts_received is the only touch signal — a per-frame snapshot Array of contact Dictionaries. There are no separate per-contact edge signals. To track a Piece across frames or detect discrete edges (tap-down / tap-up via is_touched, or a Piece being lifted), keep your own previous-frame map keyed by contact_id and diff it each frame:

var _piece_positions: Dictionary[int, Vector2] = {}
var _prev: Dictionary[int, Dictionary] = {}  # contact_id -> last frame's contact

func _ready() -> void:
    if not Board.is_on_device:
        return
    Board.input.contacts_received.connect(_on_contacts)

func _on_contacts(contacts: Array) -> void:
    var seen := {}
    for c in contacts:
        seen[c.contact_id] = true
        if c.glyph_id != 0:  # 0 is a finger; 1+ is a Piece
            _piece_positions[c.contact_id] = Vector2(c.x, c.y)
        var prior: Dictionary = _prev.get(c.contact_id, {})
        # Tap-down / tap-up edges: diff is_touched against the prior frame.
        if c.is_touched and not prior.get("is_touched", false):
            _on_tap_down(c)
        elif not c.is_touched and prior.get("is_touched", false):
            _on_tap_up(c)
        _prev[c.contact_id] = c
    # Cleanup: any contact_id missing this frame has ended.
    for id in _prev.keys():
        if not seen.has(id):
            _piece_positions.erase(id)
            _prev.erase(id)

Keeping the _prev map yourself is the supported way to get edge events — the SDK only ever hands you the current-frame snapshot, matching the Unity SDK’s per-frame model.

Players and sessions

var players: Array[BoardPlayer] = Board.session.get_players()  # array of BoardPlayer

# Show OS player selector to add a new Player
var rid := Board.session.present_add_player()
if rid >= 0:
    await Board.session.player_selector_finished
    players = Board.session.get_players()

BoardPlayer fields: player_id (String), session_id (int), display_name (String), type (BoardPlayer.Type, either PROFILE or GUEST), avatar_id (String). Use the is_guest() / is_profile() helpers instead of comparing type by hand. get_active_profile() returns a BoardPlayer or null (check if profile == null), never an empty Dictionary.

The roster is OS-owned: a game cannot silently add, remove, or swap players. The only ways to change who is playing are the OS selector overlays — Board.session.present_add_player() / Board.session.present_replace_player(session_id) — or a wholesale Board.session.reset_players(). Both selector calls take an optional trailing ai_type_indices := PackedInt32Array() filter: an empty array (the default) offers all AI types registered via set_ai_player_types(); a non-empty array restricts the selector’s “Add AI” options to that subset.

# Offer only AI types 0 and 2 in the selector
Board.session.present_add_player(PackedInt32Array([0, 2]))

Also: Board.session.is_session_ready() (the readiness check; not is_ready()), Board.session.are_services_ready(), Board.session.players_changed signal.

Save games

The async await_* helpers wrap the request-id + signal pattern with await:

var blob := PackedByteArray()
blob.resize(64)
var cover_png := PackedByteArray()  # optional cover PNG bytes; empty = no cover
var meta := await Board.save.await_create("My Save", blob, played_ms, "1.0.0", cover_png)
if meta == null:
    push_warning("save failed")

await_create() returns a BoardSaveMetadata or null (check if meta == null). A BoardSaveMetadata has fields id, description, created_at, updated_at, played_time, file_size, game_version, and player_count, plus an is_valid() helper. The save_created and save_listed signals carry typed BoardSaveMetadata too.

Also: Board.save.await_list()Array[BoardSaveMetadata], Board.save.await_load(save_id)PackedByteArray. The load method is load_data, never load. There is no direct delete; use remove_players_from_save / remove_active_profile_from_save (the OS deletes the save once no players remain).

Board.save.get_app_storage_info()Dictionary is synchronous and returns the app’s storage budget: keys total_storage (int bytes), used_storage (int bytes), remaining_storage (int bytes), and usage_percentage (float, 0.01.0). Off-device returns {}. Use it to render a storage meter or pre-flight whether a save will fit before writing it.

Pause menu

Set context once (omit keys you don’t have — do not pass empty strings as defaults), then handle the result signal:

Board.pause.set_context({
    "game_name": "My Game",
    "offer_save_option": true,
    "custom_buttons": [{ "id": "restart", "title": "Restart", "icon": Board.pause.ICON_CIRCULAR_ARROW }],
    "audio_tracks": [{ "id": "music", "name": "Music", "value": 75 }],
})

Board.pause.pause_result_received.connect(func(result: BoardPauseResult):
    match result.action:
        Board.pause.ACTION_RESUME: pass
        Board.pause.ACTION_QUIT: Board.application.quit()
        Board.pause.ACTION_SAVE_AND_QUIT: _save_then_quit()
        Board.pause.ACTION_CUSTOM_BUTTON:
            if result.custom_button_id == "restart":
                get_tree().reload_current_scene.call_deferred()
)

pause_result_received carries a BoardPauseResult (and poll_result() returns one). Its fields are action (String), custom_button_id (String), and audio_tracks (Array), plus an is_present() helper — check if not result.is_present() to test for “no result yet.” The ACTION_* / ICON_* constants on Board.pause are unchanged, and the set_context() payload is still a caller-supplied Dictionary (its custom_buttons and audio_tracks entries stay Dictionaries too).

Avatars

Board.avatar is a cached, player-scoped loader. Pass a known player’s avatar_id (from Board.session.get_players()) — a BoardPlayer’s avatar_id is a String, so wrap it in int(...). await_load_avatar returns a ready-to-display ImageTexture (already decoded), or null on failure / off-device. Textures are cached after first load, and concurrent loads of the same id coalesce onto one fetch.

var tex := await Board.avatar.await_load_avatar(int(player.avatar_id))
if tex != null:
    $TextureRect.texture = tex

Also: Board.avatar.await_default_avatar()ImageTexture (the default avatar, id 0), and Board.avatar.clear_cache() to drop cached textures.


Common patterns

State-machine with per-Piece handlers

Drive game logic from a small state machine, dispatching on glyph_id to per-Piece handler methods. Keeps the contacts handler readable as the game grows.

enum GameState { LOBBY, PLAYING, GAME_OVER }
var _state := GameState.LOBBY

func _on_contacts(contacts: Array) -> void:
    if _state != GameState.PLAYING:
        return
    for c in contacts:
        match c.glyph_id:
            GLYPH_PAWN:   _handle_pawn(c)
            GLYPH_KNIGHT: _handle_knight(c)
            _: pass  # ignore unknown glyphs

Deferred scene reload on Restart

Reloading the current scene from inside a signal handler crashes Godot in some cases — defer the reload to the next idle frame:

Board.pause.pause_result_received.connect(func(result: BoardPauseResult):
    if result.action == Board.pause.ACTION_CUSTOM_BUTTON \
       and result.custom_button_id == "restart":
        get_tree().reload_current_scene.call_deferred()
)

Mouse fallback for off-device development

When iterating in the editor or on desktop, Board.is_on_device is false and no contacts arrive. Wire a _unhandled_input(InputEvent) handler that fabricates contact Dictionaries from mouse events so you can prototype without redeploying every change:

func _unhandled_input(event: InputEvent) -> void:
    if Board.is_on_device:
        return  # let the real input pipeline drive
    if event is InputEventMouseButton and event.pressed:
        _on_contacts([{
            "contact_id": 1, "x": event.position.x, "y": event.position.y,
            "orientation": 0.0, "type_id": Board.input.TYPE_FINGER,
            "phase_id": Board.input.PHASE_BEGAN, "glyph_id": 0,
            "is_touched": true, "frame_number": 0,
        }])

Build and deploy summary

No build scripts are required. Build the APK with Godot’s own Android export, then install it onto the Board over Board Connect.

Build (Godot export)

  • In the editor: Project -> Export Project... -> Android.
  • Headless / CI: godot --headless --export-debug "Android" build/your_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 — there is no asset-staging step.

One-time setup (noCompress)

After installing the Custom Android Build Template (Project -> Install Android Build Template), open android/build/build.gradle and add the following line to the aaptOptions block, right after the ignoreAssetsPattern line:

noCompress "pck", "sparsepck"

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. This is a one-time edit per project.

Install (human, web UI)

Open the Board Connect web UI in a browser — the Board shows its address under Settings -> System — and drag the .apk onto it. Then launch the game from the Library.

Install (agent, board-connect CLI)

Agents install over Board Connect with the board-connect CLI (install it from dev.board.fun/connect/install) — no ADB and no scripts. Pair once (the user taps Approve on the device), then install, launch, and inspect. Only pair takes the Board address; every later command resolves the target from the saved default (or -b/--board / BOARD_HOST / discovery), so it needs no host:

board-connect pair <host>                  # one-time; user taps Approve on the Board
board-connect install <apk> --launch       # push the exported .apk and start it
board-connect launch <package>             # (re)start the game by package
board-connect logs <package> --follow      # stream the game's logs live until Ctrl-C
board-connect screenshot --out shot.png    # grab the current frame

Important Notes

  • Y-down coordinates, no flips. (x, y) from contacts is in display pixels with origin top-left. No transforms needed.
  • Quit cleanly with Board.application.quit(), not get_tree().quit(). The SDK version notifies BoardOS so the launcher behaves correctly; the raw Godot quit is treated like a crash.
  • is_session_ready() queries readiness. Pair with are_services_ready() if you also need to know whether the OS-level services are connected.
  • load_data, not load. load() is a GDScript built-in; using that name on a class instance produces parse errors. (There is no direct delete to begin with — remove players instead.)
  • Don’t pass empty strings as defaults. Board.pause.set_context({"game_id": ""}) silently disables the pause button because the native side treats present-but-empty as “explicit empty,” not “use default.” Omit the key entirely instead.
  • Install gate. The Board’s install gate (Board Connect, port 8843) rejects any APK that doesn’t contain lib/arm64-v8a/libboard.so (“this APK is not built with Board SDK”). The bundled board.aar includes it. This requires BoardOS 1.10.0 or later.

For more depth see the API Reference and the consumer-facing addons/board_sdk/CLAUDE.md, which ships inside every addon copy. The bootstrap-board-godot-game skill in the SDK’s bundled skills/bootstrap-board-godot-game/ directory scaffolds new projects end-to-end. File feedback at hello@board.fun. The SDK is available to download at https://dev.board.fun/. </content> </invoke>