Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/meta-declared-controls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
"kit": minor
---

Declared extra controls in `GameMeta` (wire revision 6): a new trailing
presence-guarded meta section listing inputs beyond the canonical control
vocabulary — a printable rune or a named key, each with a short display
label — so front ends on devices without the corresponding physical key
(touch) can surface each declaration as a tappable affordance that sends
exactly the declared input. Presentation metadata only: declarations change
no input interpretation, and games fully served by the canonical vocabulary
need none. Go SDK: `GameMeta.Controls` + `kit.RuneControl` /
`kit.KeyControl`; Rust SDK: `Meta::controls` + `ControlDecl`, encoded
byte-identically (golden-pinned). Validation (`wire.ValidateControls`,
enforced at `meta()` encode time): printable rune or assigned key code,
non-empty label ≤16 runes, no duplicate inputs, ≤32 declarations. ABI.md
§4.2 documents the section; GUIDE.md gains a mobile-friendly-controls
authoring section.
26 changes: 25 additions & 1 deletion ABI.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,10 @@ u32 ctxFeatures trailing large-room section (see below); bit 0 = CtxFeatRo
u16 heartbeatMS 0 = no declaration
u8 lifecycle trailing (see below); 0 resumable · 1 ephemeral · 2 resident
u16 wireRevision trailing (see below); 0 = unknown (the meta predates the field)
u16 controlCount trailing declared-controls section (see below)
per control: u8 kind (0 rune · 1 key)
· if rune: u32 rune · if key: u8 key (the input key codes, §2)
· str label
```

`slug` must be non-empty; the host refuses artifacts whose slug or version it
Expand Down Expand Up @@ -259,7 +263,7 @@ never set by the author, and declares the newest wire feature the artifact
may assume the host understands. The ledger so far: `1` config-spec section
· `2` large-room section + roster-epoch sentinels · `3` lifecycle byte ·
`4` this field itself · `5` per-member ctx character section behind
`CtxFeatCharacter`. `0` means unknown: the meta predates the field
`CtxFeatCharacter` · `6` declared-controls section. `0` means unknown: the meta predates the field
(revisions 1–3 existed before it, so artifacts of those eras cannot declare
them — only `0` or values ≥ `4` are ever observed). A hand-rolled guest
(§4.7) SHOULD stamp the revision whose features it actually uses; omitting
Expand All @@ -279,6 +283,26 @@ deploy-order rule: without it a too-new artifact surfaces only as every
delta container being rejected (a frozen screen), not as a diagnosable
version skew.

**Declared-controls section (minor addition).** A trailing section after the
wire-revision field, presence-guarded under the same rules (absent = no
declarations; encoders that know the section always write it, count `0` when
nothing is declared): the game's **declared extra controls** — inputs beyond
the canonical control vocabulary (Up/Down/Left/Right via arrows-or-hjkl,
Confirm via Enter/Space, Back via Esc/q), each paired with a short display
label. A front end on a device that cannot produce the declared key (a touch
screen without a physical keyboard) surfaces each declaration as a tappable
affordance that sends **exactly the declared input**, indistinguishable from
the key itself. Declarations are presentation metadata only: they change no
input interpretation, and a game fully served by the canonical vocabulary
needs none. Each entry is `u8 kind` (`0` rune · `1` key) followed by the
input value (`u32 rune` for kind 0; `u8 key` for kind 1, the same key codes
as the `input` export) and `str label`. An entry's size depends on its kind,
so decoders MUST fail on an unknown kind rather than skip it (it cannot be
framed past). Declared controls must satisfy (`wire.ValidateControls`, the
shared rule set): a printable rune (≥ U+0020) or an assigned key code (1–9);
a non-empty label of at most 16 runes; no duplicate inputs; at most 32
declarations. SDKs enforce these at `meta()` encode time.

### 4.3 Frame (the delta container and its cell)

A frame is delivered as a **frame-delta container** (§4.5), a variable-length
Expand Down
29 changes: 29 additions & 0 deletions GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,35 @@ Declare your input context once (`r.SetInputContext`): `CtxNav` for menus and
move-around games, `CtxCommand` when letters are commands (blackjack's
h/s/d), `CtxText` for typing games.

### Mobile-friendly controls: declare what's beyond the vocabulary

Players on phones reach the arcade through a touch deck that produces exactly
the canonical vocabulary — arrows, Confirm, Back — plus the OS soft keyboard
when your game publishes `CtxText`. A game that resolves everything through
`Resolve` is fully playable on touch with zero work.

If your game reads inputs **beyond** the vocabulary — letter commands like
`r` to resign, or a named key like Backspace to undo — declare them in
`GameMeta.Controls` so touch front ends can surface each one as a tappable
button carrying your label:

```go
Controls: []kit.ControlDecl{
kit.RuneControl('r', "RESIGN"),
kit.RuneControl('d', "DRAW"),
kit.KeyControl(kit.KeyBackspace, "UNDO"),
},
```

A declaration is presentation metadata only — tapping the button delivers the
exact declared input to `OnInput`, indistinguishable from the key, and
declarations never change how any input resolves. Keep labels short (≤16
runes; they render as small chips), declare at most the handful of inputs
that matter (≤32), and don't declare what the vocabulary already covers.
Expect players whose only inputs are the canonical actions, your declared
controls, and (in `CtxText`) free text — if that set can't play your game,
neither can a phone.

## Action games: held keys, raw input, geometry

Terminals (and SSH) deliver only discrete key events — **there is no key-up**,
Expand Down
2 changes: 1 addition & 1 deletion crossverify/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions internal/game/codec.go
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,19 @@ func encodeMeta(m GameMeta) []byte {
if err := wire.ValidateLifecycle(wm.Lifecycle, wm.MinPlayers); err != nil {
panic("kit: invalid GameMeta: " + err.Error())
}
// Declared-controls trailer, validated under the same fail-fast posture
// as config specs.
for _, cd := range m.Controls {
wm.Controls = append(wm.Controls, wire.ControlDecl{
Kind: uint8(cd.Input.Kind),
Rune: cd.Input.Rune,
Key: uint8(cd.Input.Key),
Label: cd.Label,
})
}
if err := wire.ValidateControls(wm.Controls); err != nil {
panic("kit: invalid GameMeta.Controls: " + err.Error())
}
// Stamp the wire revision this kit was built against — not
// author-settable; the host uses it to warn on or refuse artifacts
// declaring a revision above its own (deploy-order enforcement and
Expand Down
46 changes: 46 additions & 0 deletions internal/game/controls_meta_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package game

import (
"reflect"
"testing"

"github.com/shellcade/kit/v2/wire"
)

// TestEncodeMetaMapsControls pins the authoring → wire mapping for declared
// controls: RuneControl/KeyControl literals land as the wire kinds/values, in
// declaration order.
func TestEncodeMetaMapsControls(t *testing.T) {
m := GameMeta{
Slug: "chess", Name: "Chess", MinPlayers: 2, MaxPlayers: 2,
Controls: []ControlDecl{
RuneControl('r', "RESIGN"),
KeyControl(KeyBackspace, "UNDO"),
},
}
out, err := wire.DecodeMeta(encodeMeta(m))
if err != nil {
t.Fatal(err)
}
want := []wire.ControlDecl{
{Kind: wire.InputRune, Rune: 'r', Label: "RESIGN"},
{Kind: wire.InputKey, Key: wire.KeyCodeBackspace, Label: "UNDO"},
}
if !reflect.DeepEqual(out.Controls, want) {
t.Fatalf("controls mismatch:\n got=%+v\nwant=%+v", out.Controls, want)
}
}

// TestEncodeMetaPanicsOnInvalidControls pins the fail-fast posture: an
// invalid declaration panics at meta() encode time, not at load time.
func TestEncodeMetaPanicsOnInvalidControls(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("encodeMeta accepted an empty control label")
}
}()
encodeMeta(GameMeta{
Slug: "g", Name: "G", MinPlayers: 1, MaxPlayers: 1,
Controls: []ControlDecl{RuneControl('r', "")},
})
}
29 changes: 29 additions & 0 deletions internal/game/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,35 @@ type GameMeta struct {
// resident-with-MinPlayers>1 are authoring bugs and panic at meta
// encode time.
Lifecycle Lifecycle

// Controls optionally declares the game's extra controls: inputs beyond
// the canonical vocabulary (a raw rune like 'r', or a named key like
// KeyBackspace), each with a short display label. Front ends on devices
// without the corresponding physical key (touch) surface each
// declaration as a tappable affordance that sends exactly the declared
// input — presentation metadata only; declarations change no input
// interpretation. Nil/empty means no declarations, and a game fully
// served by the canonical vocabulary needs none. Invalid declarations
// are an authoring bug and panic at meta encode time.
Controls []ControlDecl
}

// ControlDecl declares one extra control: the exact Input it sends (a
// printable rune or a named key) and a short display label of at most 16
// runes. Build with RuneControl / KeyControl.
type ControlDecl struct {
Input Input
Label string
}

// RuneControl declares a printable-rune control, e.g. RuneControl('r', "RESIGN").
func RuneControl(r rune, label string) ControlDecl {
return ControlDecl{Input: Input{Kind: InputRune, Rune: r}, Label: label}
}

// KeyControl declares a named-key control, e.g. KeyControl(KeyBackspace, "UNDO").
func KeyControl(k Key, label string) ControlDecl {
return ControlDecl{Input: Input{Kind: InputKey, Key: k}, Label: label}
}

// Lifecycle is the room end-of-life declaration.
Expand Down
9 changes: 9 additions & 0 deletions kit.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,14 @@ const (
// platform's canonical control vocabulary, reimplemented locally.
func Resolve(in Input, ctx InputContext) Action { return game.Resolve(in, ctx) }

// RuneControl declares a printable-rune extra control for GameMeta.Controls,
// e.g. RuneControl('r', "RESIGN").
func RuneControl(r rune, label string) ControlDecl { return game.RuneControl(r, label) }

// KeyControl declares a named-key extra control for GameMeta.Controls,
// e.g. KeyControl(KeyBackspace, "UNDO").
func KeyControl(k Key, label string) ControlDecl { return game.KeyControl(k, label) }

// ---- rooms & results -------------------------------------------------------------

type (
Expand All @@ -72,6 +80,7 @@ type (
GameMeta = game.GameMeta
LeaderboardSpec = game.LeaderboardSpec
ConfigKeySpec = game.ConfigKeySpec
ControlDecl = game.ControlDecl
Lifecycle = game.Lifecycle
ConfigType = game.ConfigType
Direction = game.Direction
Expand Down
2 changes: 1 addition & 1 deletion rust/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 17 additions & 0 deletions rust/src/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,23 @@ impl Key {
_ => return None,
})
}

/// Key → wire value (ABI.md §2): the inverse of `from_wire`, used when
/// the SDK itself encodes an input (declared controls in the meta
/// payload). Internal key additions must update both directions.
pub(crate) fn to_wire(self) -> u8 {
match self {
Key::Enter => 1,
Key::Backspace => 2,
Key::Esc => 3,
Key::Tab => 4,
Key::Up => 5,
Key::Down => 6,
Key::Left => 7,
Key::Right => 8,
Key::CtrlC => 9,
}
}
}

/// The SDK-neutral input event (Go's `kit.Input`, as a sum type).
Expand Down
13 changes: 7 additions & 6 deletions rust/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,8 @@ pub use frame::{
pub use input::{Action, Input, InputContext, Key};
pub use room::Room;
pub use types::{
Aggregation, Character, ConfigKeySpec, ConfigType, Direction, Kind, Leaderboard, MergeRule,
Meta, MetricFormat, Mode, Outcome, Player, PlayerResult, RoomConfig, Status,
Aggregation, Character, ConfigKeySpec, ConfigType, ControlDecl, Direction, Kind, Leaderboard,
MergeRule, Meta, MetricFormat, Mode, Outcome, Player, PlayerResult, RoomConfig, Status,
CTX_FEAT_CHARACTER, CTX_FEAT_ROSTER_EPOCH, Lifecycle,
};

Expand Down Expand Up @@ -127,10 +127,11 @@ pub trait Handler {
/// Everything a game file needs: `use shellcade_kit::prelude::*;`
pub mod prelude {
pub use crate::{
character_cell, Action, Aggregation, Cell, Character, Color, Direction, Frame, Game,
Handler, Input, InputContext, Key, Kind, Leaderboard, MergeRule, Meta, MetricFormat, Mode,
Outcome, Player, PlayerResult, Room, RoomConfig, Status, Style, ATTR_BOLD, ATTR_DIM,
ATTR_REVERSE, ATTR_UNDERLINE, COLS, CYAN, DIM_GRAY, GREEN, RED, ROWS, WHITE, YELLOW,
character_cell, Action, Aggregation, Cell, Character, Color, ControlDecl, Direction,
Frame, Game, Handler, Input, InputContext, Key, Kind, Leaderboard, MergeRule, Meta,
MetricFormat, Mode, Outcome, Player, PlayerResult, Room, RoomConfig, Status, Style,
ATTR_BOLD, ATTR_DIM, ATTR_REVERSE, ATTR_UNDERLINE, COLS, CYAN, DIM_GRAY, GREEN, RED,
ROWS, WHITE, YELLOW,
};
}

Expand Down
19 changes: 19 additions & 0 deletions rust/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,24 @@ pub struct Meta {
/// per slug (takes effect only when the platform grants it).
/// Resident with `min_players > 1` panics at `meta()` encode time.
pub lifecycle: Lifecycle,

/// Declared extra controls (`&[]` = none declared): inputs beyond the
/// canonical vocabulary — a printable rune or a named key — each with a
/// short display label, surfaced by touch front ends as tappable
/// affordances that send exactly the declared input. Presentation
/// metadata only; declarations change no input interpretation. Validated
/// at `meta()` encode time — an invalid declaration is an authoring bug
/// and panics there.
pub controls: &'static [ControlDecl],
}

/// One declared extra control ([`Meta::controls`]): the exact input it sends
/// and a short display label of at most 16 chars, e.g.
/// `ControlDecl { input: Input::Char('r'), label: "RESIGN" }`.
#[derive(Clone, Copy, Debug)]
pub struct ControlDecl {
pub input: crate::input::Input,
pub label: &'static str,
}

/// Room end-of-life declaration (wire values 0/1/2).
Expand Down Expand Up @@ -298,6 +316,7 @@ impl Meta {
config: &[],
ctx_features: 0,
heartbeat_ms: 0,
controls: &[],
lifecycle: Lifecycle::Resumable,
};
}
Expand Down
Loading
Loading