Controller+BETADownload

surface reference

Compose a phone controller from data. Import from @controller-plus/sdk/surface or /sdk/src/surface.mjs. See Custom controllers for every part option, event payload and complete game examples.

Builders

Builders return plain objects without validation. Options are copied into the part; use normalizeSurface before doing layout yourself. Hosts select a compatible view, and clients normalize it before drawing.

Full signature Return
surface({ id, root, turn = null, accent = null, minApp = null }) { kind: 'surface', surface: { v: 1, id: String(id), root, turn?, accent? }, minApp? }. Truthy optional fields are included. minApp is metadata; viewFor does not enforce it. Use room requires for app gating.
row(children, options = {}) { type: 'row', children, ...options }, horizontal split.
column(children, options = {}) { type: 'column', children, ...options }, vertical split.
spacer(options = {}) { type: 'spacer', ...options }, empty layout space.
lcd(options = {}) { type: 'lcd', ...options }, screen with text, timer, entry and other indicators.
keys(list, options = {}) { type: 'keys', keys: list, ...options }, tray of touch keys.
canvas(options = {}) { type: 'canvas', ...options }, bounded drawing paper.
keyboard(options = {}) { type: 'keyboard', ...options }, drawn text input.
trackpad(options = {}) { type: 'trackpad', ...options }, relative pointer/click/scroll input.
drawingTools({ colors = [], widths = [], undo = true, clear = true } = {}) Key array: ink-N color toggles, width-N width toggles, optional undo/clear. First color and middle width start selected.

SURFACE_VERSION is 1. SURFACE_APPS is frozen { ios: '1.0.0', android: '1.0.0' }. These are feature minimums, not a claim about installed versions.

Normalization

normalizeSurface(input): SurfaceDescription | null accepts the inner { v, id, root, turn?, accent? }, not the outer view. It returns a clean copy, drops unknown nodes/options, clamps numbers and truncates text. Wrong version, missing root, or a wholly undrawable tree returns null. It is not a transport size validator or a sanitizer for arbitrary event payloads: a key's object payload is retained. Use JSON-safe trusted descriptions.

The budget is 160 nodes plus keys, depth 0–6, 12 children per group and 32 keys per tray. See all normalized bounds. Malformed non-JSON values can still raise ordinary JavaScript errors; no custom error class is exported.

Layout helpers

These operate on a normalized description. Rectangles are [x, y, width, height] in design units. A rendered phone uses a 450-unit design height and a screen fit that accounts for safe areas.

Full signature Parameters and return
layoutSurface(desc, fit) fit: { size: [W, H], side, bottom }; returns { content: Rect, parts: [{ node, rect, path, menu? }] }. Rows/columns allocate fixed sizes before weighted space; canvas preserves aspect. path is child indices joined by dots.
keyGrid(part, rect) Keys node and tray slot; returns [{ key, rect }]. Auto-selects columns when absent. Uses 12-unit inner padding and gaps. Round keys cap at 96 units, or 190 for a lone buzzer; screen keys cap at 132.
keyTray(part, rect): Rect Bounding tray around the key cluster with 12-unit padding. Empty grid returns input rect.
hitSurface(layout, point) Return first leaf part containing [x, y], or null. Despite the helper's name, it does not identify an individual key; use keyGrid for that.
timerLeft(timer, receivedAt, now): number timer.left is seconds; timestamps are milliseconds. Subtract elapsed time, clamped at zero.

The content box starts at [fit.side + 12, 14], with bottom keep-out fit.bottom + 12. Include an LCD: the topmost, rightmost LCD receives the single MENU key. Excessively large fixed sizes or gaps can produce unusable layouts; normalization is not a guarantee that arbitrary trees fit. Keep controls few and inspect target screen sizes.

Compatibility helpers

Full signature Behavior
supportsSurfaces(app): boolean true for no app/platform, web, or browser; iOS/Android require the minimum in SURFACE_APPS. Unknown platforms return false.
viewFor(view, app) For a surface-capable app, strip legacy from a surface. For an old app, return view.legacy or a waiting view asking for an update. For other view kinds, use asSurface if supported; otherwise pass through.
asSurface(view) Convert waiting and choices into surfaces with legacy: view; pass other kinds through. Choices cap at 12 and emit { target: choice.id } under choice.event || 'choice'. submitted: true replaces controls with an LCD.

These helpers have no events or I/O. Builders and layout do not emit player input. The phone renderer emits custom-controller events, wrapped by your room's event callback.

Build, inspect and select a view

import { surface, column, row, lcd, keys, canvas, keyboard, trackpad, spacer,
  drawingTools, normalizeSurface, layoutSurface, keyGrid, keyTray, hitSurface,
  timerLeft, supportsSurfaces, viewFor, asSurface, SURFACE_APPS, SURFACE_VERSION
} from '@controller-plus/sdk/surface';

const view = surface({ id: 'draw/1', root: column([
  lcd({ size: 92, title: 'Draw a star', timer: { left: 30, total: 30 } }),
  row([keys(drawingTools({ colors: ['#292d37'], widths: [2, 4, 8] }), { size: 180 }),
    canvas({ aspect: 1.5 })]),
]) });
view.legacy = { kind: 'waiting', title: 'Drawing', text: 'Use a browser to draw' };
const desc = normalizeSurface(view.surface);
const layout = layoutSurface(desc, { size: [940, 450], side: 0, bottom: 0 });
const tray = layout.parts.find(p => p.node.type === 'keys');
console.log(keyGrid(tray.node, tray.rect), keyTray(tray.node, tray.rect));
console.log(hitSurface(layout, [20, 20]), timerLeft({ left: 30 }, 1000, 2000));
console.log(viewFor(view, { platform: 'ios', version: '0.1.0' }).kind); // waiting
console.log(supportsSurfaces({ platform: 'web' }), SURFACE_APPS, SURFACE_VERSION);
console.log(asSurface({ kind: 'waiting', title: 'Lobby', text: 'Ready' }));
console.log(keyboard({ charset: 'text' }), trackpad(), spacer({ size: 20 }));