← All guides

Game reference

What a Senet game is made of, what the platform calls, and what it hands back. For a step-by-step walkthrough, read Make Your Own Game. This page is the reference: the contracts, the limits, and the conventions the reference games follow.

A game is two kinds of code:

  • Logic (logic.ts): pure functions plus a declarative MANIFEST. It runs on the server, which enforces the rules. The browser’s game frame also runs it, for UIs (sdk.logic) and for replays.
  • UI modules (ui/{module}/main.ts): render the state a viewer is allowed to see and emit moves. They run in a sandboxed iframe.

Both are TypeScript or JavaScript. States and moves are strings, defined by the game.

Project layout

logic.ts                         rules + MANIFEST (required)
about.md                         the game page's description (Markdown)
ui/{module}/main.ts              a UI module; `ui/default/` by convention
ui/{module}/{skin}.css           its skins; the file name is the skin's identity
ui/{module}/assets/…             files only that module uses (sdk.asset(path))
asset-packs/{axis}/{pack}/…      files shared by modules, one pack per axis (sdk.asset(axis, path))
i18n/{locale}.json               UI translations (sdk.t)

UI modules and skins are discovered from disk, not declared: a directory under ui/ with a main.ts (or main.js) is a module, and each .css file next to it is a skin. Never name a skin default.css. The skin’s identity is its file name, and which skin is the default is the MANIFEST’s choice.

Asset files may be .svg .png .jpg .jpeg .gif .webp .woff .woff2 .ttf .eot .js .css .txt. The editor uploads up to 20 files at a time, 2 MB each. A .txt file is how a pack carries its LICENSE.txt. Only assets you’re allowed to redistribute belong here.

Logic: the environment

logic.ts is loaded from its source into a locked-down worker:

  • Self-contained. It can’t import anything: no relative imports, no https:/npm:/jsr: modules, no Node built-ins.
  • No I/O. No timers (setTimeout and friends), no Deno.*, no storage, no network. Every SDK function is synchronous.
  • Frozen built-ins. Array.prototype, JSON, Math and the rest are frozen, so patching them throws. Your module’s exports are frozen too.
  • Pure. Everything a function needs is in its arguments. Calls are spread across a pool of workers, and a module can be reloaded at any time, so state kept in module-level variables between calls is unreliable. Module-level constants are fine.
  • Deterministic. Given the same arguments and the same senet.random results, a function must return the same answer. Replays, the waiting room’s preview and hidden-information detection all re-run your code and compare its output. Draw randomness only through senet.random (see Randomness below). Math.random works, but its values aren’t recorded, so a replay comes out different.
  • Runs in two places. It runs on the server (Deno) and in the browser frame (transpiled to logic.js). Don’t use APIs specific to either one: no DOM, no Deno.

A thrown Error is how logic refuses something. The message is shown to the player for a refused move.

Each call has 5 seconds. A whole-match replay has 10 seconds.

MANIFEST

MANIFEST is the single source of a game’s declarative metadata. Export it with an accessor:

export const MANIFEST = { … };
export function getManifest() { return MANIFEST; }
Field Type Meaning
title string Catalog name. Copied into the database when a version is approved.
description string Short pitch shown in the catalog and on the game page.
players string[] Role names in seat order, one seat per role. Seat N plays players[N-1]. Every player argument the platform passes is one of these.
minPlayers / maxPlayers number How many seats must be filled before the match can start (capped at the number of roles).
tags string[] Catalog tags: lowercase letters, digits and single hyphens, up to 50 characters each, at most 20. Invalid names are dropped.
variantOf string The slug of the game this is a variant of. Create a Variant writes it. Only a slug naming another game on the server is honored.
moveNotation / stateNotation string Names of your move and state grammars, e.g. "algebraic", "compact", "json". Use generic names, not ones prefixed with the game’s name.
params ParamDescriptor[] Per-match settings (see Params below).
appearance object Presets and the asset packs each module supports (see Appearance below).
libs {name: version} Platform JavaScript libraries the UI imports (see Platform libraries below).
defaultLocale string The locale whose i18n/{locale}.json the UI gets.
defaultUi {module, skin} Legacy: use appearance presets. Still honored, and wins over appearance when present.

The platform reads MANIFEST by calling getManifest() in the worker. Validate in the editor checks that players can be read. A missing or unreadable MANIFEST leaves the game without roles, and it can’t be played.

Params

Each descriptor is {key, label, type, default, …}:

type Extra fields What the game gets
boolean true / false
select options: [{value, label}] One of the options’ values; an unknown value becomes default
number min, max A number clamped to [min, max]; unparsable input becomes default
string Trimmed text; empty becomes default

enabledWhen: {key, value} makes a param apply only while another param has that value. The two are compared as text. The form disables it otherwise, and the game then gets null for it. Use a “Random / Choose” select plus a dependent number instead of a magic value such as -1 meaning random:

params: [
  { key: "start", label: "Starting position", type: "select", default: "random",
    options: [{ value: "random", label: "Random" }, { value: "choose", label: "Choose" }] },
  { key: "position", label: "Position number", type: "number", default: 518, min: 0, max: 959,
    enabledWhen: { key: "start", value: "choose" } },
],

newGame and startGame receive the settings as a JSON string ("{}" when the game declares none). Parse defensively. Every match shows its settings, by label, on the game page’s list of open matches and on the play page, so a joiner knows what they’re joining.

Appearance

appearance: {
  modules: {                                   // per module: the packs it supports, per axis
    tiles: { board: ["wood", "slate"], pieces: ["classic", "neon"] },
    default: { pieces: [null, "rhosgfx"] },    // null: also works with no pack
  },
  presets: {                                   // named looks: a module, a skin, a pack per axis
    classic: { module: "default", skin: "classic" },
    tiles:   { module: "tiles", skin: "classic", board: "wood", pieces: "classic" },
    glyphs:  { module: "default", skin: "classic", pieces: null },
  },
  default: "classic",                          // the default preset
},
  • The picker. Players choose a preset in the play page’s appearance picker. Presets whose module and skin exist on disk are listed, the default first, then the rest alphabetically. A Customize panel picks the module, skin and pack per axis directly. A game without appearance gets bare module and skin choices. The choice is saved per user and per game.
  • Which pack is active. When a match opens, per axis: the player’s saved choice, else the default preset’s, else the first entry of the module’s list. Picking a preset applies its own choices, else the default preset’s, else the first listed. Each must be one the module allows. Put null first to make “no pack” the last fallback.
  • Optional axes. null in a module’s list makes the axis optional. The picker offers None, and sdk.asset(axis, …) returns null, so the module has to be able to draw without the pack.
  • An axis a preset leaves out. The preset makes no choice for it, so the default preset’s choice applies, then the module’s first listed pack. An explicit null is a choice (“no pack”); a missing key isn’t.
  • What the picker offers. For each axis, the packs listed for the module that also exist on disk.
  • The default skin. When no preset names one, the first skin alphabetically.

Platform libraries

Shared libraries live in a catalog the platform’s admins curate. Declare the ones your UI uses:

libs: { three: "0.123.0" },

The frame installs an import map before your module loads, so UI code imports the library by its bare name (import * as THREE from "three"). A version is approved only if every declared library and version is in the catalog. Libraries are for UI modules. Logic can’t import anything.

Logic: the exports

Required

Export Returns Called
getManifest() MANIFEST Whenever the platform reads metadata
newGame(paramsJson) the initial state When a match is created. The waiting room shows this state, and the match starts from it.
getActivePlayers(state) string[] After every state change. The roles allowed to move now: one for a turn-based game, several for simultaneous moves, [] when it’s over.
applyMove(state, move, player) the new state For a move by player, who is always one of the active players. Throw to refuse the move.
getPlayerState(state, player) the state as player sees it For each role and for spectators (player is null), after every state change
getWinner(state) a role name, "draw", or null After every accepted move. Anything but null ends the match with that result.

Optional

Export Returns Called
startGame(state, paramsJson) the started state Once, when the match goes from waiting to active (see Starting a match below)
getPlayerMoves(moves, viewer, state) one entry per move Whenever a viewer’s move history is shown (see Move history below)
getValidMoves(state, player) string[] Not called by the server. The new-project starter’s UI uses it to offer moves, and UIs may call it through sdk.logic.
isValidMove(state, move, player) boolean Not called by the server. A cheap, non-throwing check for UIs.
parseStateForUI(state) a view for rendering Not called by the platform. The convention for UIs to read the state (see Conventions of the reference games below).

A missing optional export is simply not called. An error your function throws is never mistaken for a missing export.

States and moves

A state is a string you define: JSON, or a compact notation like FEN. The reference games use compact notations. A move is a string the UI emits and applyMove parses.

The server never sends the full state to a browser. Each viewer gets getPlayerState‘s answer for their seat, or the spectator’s. Make sure getPlayerState doesn’t throw: if it fails for a viewer, that viewer gets no board at all, just a note that it can’t be shown, and your match counts as hiding information. A move is at most 4 KB, and longer moves are refused before your code sees them.

Starting a match

  1. Creation. newGame(paramsJson) builds the state the waiting room shows. Whatever it draws from senet.random is saved, so the preview and the actual start are the same board.
  2. Start. When the seats are settled and the version is pinned, the platform calls startGame(state, paramsJson) once, if you export it. Its result becomes the match’s state. It isn’t a move and nobody makes it. Its draws are recorded as history row 0: no player, empty move, shown as “Match started — drew 437” unless getPlayerMoves rewords or hides it. If it throws, the match doesn’t start.
  3. Moves. applyMove, then getActivePlayers, getPlayerState for every viewer, and getWinner.

Randomize the start in startGame (a shuffled deck, a Chess960 position) and keep newGame‘s state neutral. Otherwise the waiting room reveals the start before anyone has joined.

A match always runs the version of the game it started with, even if a newer version is approved mid-match.

Hidden information

getPlayerState(state, player) filters state for one viewer. player is a role name, or null for spectators. For a fully visible game, return state itself.

The platform detects hidden information rather than asking you to declare it. A match counts as hidden-information the first time any viewer is shown less than everything:

  • a viewer’s state differs from the full state (compared as strings), or
  • a history row is hidden from someone, shown with other text than was played, or shown with different draw text to different viewers.

So a fully visible game must return the same string. A state that’s re-serialized but equivalent counts as hidden. Hidden-information matches get restricted replays: each viewer sees only their own perspective, and players choose whether to reveal theirs afterwards.

Move history (getPlayerMoves)

getPlayerMoves(moves, viewer, state): Array<string | { move: string, draws?: string } | null>
  • moves is the full canonical log: {seq, player, move, draws} per row, in order. Row 0 is the start (seq 0, move "") and has no player key: comparing m.player === viewer with a null spectator must not take the start for the spectator’s own move. draws are the recorded senet.random entries (numbers only, see Randomness below).
  • viewer is a role name, or null for a spectator.
  • state is the full state at that point.
  • The return value is a parallel array, one entry per row:
    • a string: the move text, with the default draw text;
    • {move, draws}: your own draw text, up to 1,000 bytes. Anything but a non-empty string there shows no draw text at all.
    • null: hides the row entirely.

Without the export, everyone sees the canonical moves with the default draw text.

It’s called in two shapes. When a page loads a match’s history, the whole log goes in one call with the current state. During play, each new move is passed alone — moves is a one-row array [row], with the state after that move — once per viewer. So answer each row from the row, the viewer and the state; don’t rely on a row’s index in moves or on seeing earlier rows.

It fails closed. Once a game is known to export getPlayerMoves, any failure (a throw, a timeout, an answer of the wrong length or shape) shows each affected row as a placeholder (“hidden move”), with its number and player but neither move nor draws.

Move text is shown up to 1,000 bytes, cut with “…”. Draw text over 1,000 bytes isn’t shown.

Randomness: senet.random

A global senet object is available everywhere game code runs, in logic and in UIs:

Call Returns
senet.random.int(min, max) An integer, both ends inclusive. The range holds at most 2³² values.
senet.random.roll("2d6") An array of N values from 1 to M ("d20" = "1d20"). 1–1000 dice of up to 2³² sides.
senet.random.select(list) One element of a non-empty array
senet.random.shuffle(list) A new shuffled array; list isn’t modified
senet.random.weighted([[item, weight], …]) One item, with probability weight ÷ total. Weights must be numbers greater than 0.

Draws are unbiased (crypto.getRandomValues, with rejection sampling). Lists hold at most 10,000 items. senet is frozen and can’t be replaced. It also can’t be rigged by patching globals or prototypes, though the arrays you pass are read as given.

What’s recorded. On the server, the draws made by newGame, startGame and an accepted applyMove are recorded (a move that throws draws nothing). Each call is limited to 1,000 draws and 64 KB of recorded data. Recorded entries hold numbers only, never your items:

  • int and roll: the numbers drawn;
  • select and weighted: the index chosen;
  • shuffle: the permutation.

So a hidden hand passed to shuffle is never stored. Replays feed the recorded results back, which is why logic must be deterministic. Draws made anywhere else (in getPlayerState, getPlayerMoves, or in the browser) are plain randomness: not recorded, and not reproducible.

Who sees them. Viewers never get the draws themselves, only display text. By default:

  • roll and int show their numbers (“b2 — rolled 3, 5”);
  • select and weighted say “drew one”;
  • shuffle says “shuffled”.

To change that per viewer, return {move, draws} from getPlayerMoves. The draws themselves stay on the server until the match is over.

UI modules

A UI module is ui/{module}/main.ts, transpiled on its own to an ES module (it isn’t bundled). It runs in a sandboxed iframe at /game-frame.

Exports

Export Purpose
mount(el, state, sdk) Render into el (#game-root). May be async; the frame waits for it.
update(state) A new state to draw
onMove(cb) Keep cb and call cb(move) with a move string to submit a move
destroy() Remove listeners; the frame is cleared after it
setSeat(seat) (optional) The seat whose side the board is drawn from: 1-based, or null for a spectator
setStatus(status) (optional) "waiting", "active", "completed", or "replay". "replay" means the board takes no moves: the replay viewer, or a player looking from another side.
setResult(result) (optional) When completed: the winning role, "draw", or "resign"

state is always the viewer’s state from getPlayerState, never more. The player of seat N is sdk.players[N-1]. Don’t hard-code “seat 1 is the first role”.

The sdk object

The third argument to mount, frozen:

Field What it is
sdk.logic Your logic module, already imported, so mount can stay synchronous. null if logic.ts failed to load. Show an error then.
sdk.players MANIFEST.players, the roles by seat
sdk.asset(path) URL of ui/{module}/assets/{path}, with its content hash
sdk.asset(axis, path) URL of asset-packs/{axis}/{pack}/{path} for the active pack, or null when no pack is active on that axis
sdk.t(key, vars?) key translated into the active locale (see Translations below)
sdk.logicUrl URL of the transpiled logic.js. Prefer sdk.logic.

Asset URLs carry their content hash (?v=) and are served only with it, so build URLs with sdk.asset, never by hand. For the same reason a static relative import of another file won’t load. Code shared between logic and UI belongs in logic.ts (sdk.logic). A third-party library belongs in the platform catalog (MANIFEST.libs), or can be uploaded as a .js asset and loaded with import(sdk.asset("lib.js")).

Switching a module, skin or pack remounts the frame, so sdk always reflects the current choice.

The sandbox

The frame is sandbox="allow-scripts" with an opaque origin. Game code there has:

  • no access to the host page’s DOM, cookies or storage;
  • no network: fetch, XHR and WebSocket are blocked (connect-src 'none');
  • no nested frames.

It talks to the page only through the platform’s bridge.

Layout. CSS @media queries don’t work inside the frame. #game-root has container-type: inline-size, so write responsive layouts with @container queries. The frame reports its height to the page as it changes.

Styling. The platform’s stylesheet (app.css) is loaded in the frame, and the page’s color scheme is mirrored on <html data-theme>, so skins can follow the user’s scheme. The active skin’s CSS is loaded before mount.

Translations

Wrap user-facing text in sdk.t("English text", {name: value}). The literal is the key, gettext style. A missing translation returns the key, with {name} placeholders filled from vars, so wrapping changes nothing until an i18n/{locale}.json (a flat {"English text": "Texto"} map) exists.

The active locale is MANIFEST.defaultLocale if that file exists, else en, else the first locale alphabetically. There’s no per-user locale choice yet.

Conventions of the reference games

The reference games model current practice: tic-tac-toe, connect-four, hex, chess, chess960, nine-mens-morris, onitama, quarto, quoridor and stratego. You can read every file of each on its project page, such as /projects/tic-tac-toe.

Notation lives in logic. logic.ts is the single source of truth for the state and move grammars. It exports parseStateForUI(state), which returns a view for rendering that uses the same cell tokens the logic uses. UI modules call it, plus getWinner and friends, through sdk.logic in a synchronous mount. They never re-implement the grammar or the rules. When sdk.logic is null, they show an explicit error rather than falling back to a second parser. If a renderer needs another ordering (the 3D Connect Four board is Y-up, so its rows are bottom-up), treat it as a coordinate choice, not a format adapter.

Public surface first. Files put what they export first, in this order:

  • logic.ts: header comment (document your notations there) → MANIFEST / getManifest → // --- Required SDK exports --- → // --- Optional SDK exports --- → // --- UI helper export --- (parseStateForUI) → // --- Internal ---.
  • ui/*/main.ts: header → // --- SDK exports --- → // --- Internal ---.

Write export on each declaration, never in a trailing export { … } list. Hoisting makes this ordering safe, since exports only call internals at match time.

Match notation and replays

Finished matches can be replayed step by step, and matches can be written into posts as ```match blocks. The format is PGN generalized to any game:

```match chess960
[Event "Casual game"]
[White "Ana"]
[Version "2"]

{[%draws 19] A note on the start.}
1. e2e4 e7e5
2. g1f3 {[%draws 3, 5] A note on this move}
```

What it means for your game:

  • Moves are your move strings, separated by spaces. A move containing spaces, or one that would read as syntax, is quoted ("c3 3"). Where several players can move, a move is prefixed with its role (Blue: a4).
  • Draws. [%draws …] gives the senet.random results of the step it follows: the start before the first move, otherwise the move. A replay must be given every result your code draws, and no extra ones.
  • Positions. [Start "…"] (or [FEN "…"]) and [%start "…"] are positions written in your state notation. The replay takes the string as your state, as is.
  • Notations. A [MoveNotation] or [StateNotation] tag other than your MANIFEST’s own is refused.
  • Determinism. A replay runs your newGame, startGame and applyMove with the given draws, then getPlayerState and getPlayerMoves for each perspective. It must reproduce the match, which is why logic has to be pure and deterministic.

Limits

What Limit
A logic call 5 s
A whole-match replay 10 s
A move 4 KB
Move text in the history 1,000 bytes (cut with “…”)
A game’s own draw text 1,000 bytes (longer shows none)
Draws per call 1,000 draws, 64 KB recorded
roll 1–1000 dice, up to 2³² sides
Lists passed to senet.random 10,000 items
Tags 20, 50 characters each
Asset uploads 20 files per batch, 2 MB each
A match in notation 256 KB, 1000 moves, 4 KB per move, 1000 bytes per tag (Params/Start/FEN excepted)