# 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](https://docs.dev.board.fun/godot/reference/api) and the [FAQ](https://docs.dev.board.fun/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.

```gdscript
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_id` — `glyph_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:

```gdscript
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

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

```gdscript
# 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`:

```gdscript
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.0`–`1.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:

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

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

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

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

```gdscript
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](https://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](https://docs.dev.board.fun/godot/reference/api) 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](mailto:hello@board.fun). The SDK is available to download at [https://dev.board.fun/](https://dev.board.fun/).
