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 declarativeMANIFEST. 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 (
setTimeoutand friends), noDeno.*, no storage, no network. Every SDK function is synchronous. -
Frozen built-ins.
Array.prototype,JSON,Mathand 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.randomresults, 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 throughsenet.random(see Randomness below).Math.randomworks, 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, noDeno.
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
appearancegets 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
nullfirst to make “no pack” the last fallback. -
Optional axes.
nullin a module’s list makes the axis optional. The picker offers None, andsdk.asset(axis, …)returnsnull, 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
nullis 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
-
Creation.
newGame(paramsJson)builds the state the waiting room shows. Whatever it draws fromsenet.randomis saved, so the preview and the actual start are the same board. -
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” unlessgetPlayerMovesrewords or hides it. If it throws, the match doesn’t start. -
Moves.
applyMove, thengetActivePlayers,getPlayerStatefor every viewer, andgetWinner.
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>
-
movesis the full canonical log:{seq, player, move, draws}per row, in order. Row 0 is the start (seq0,move"") and has noplayerkey: comparingm.player === viewerwith anullspectator must not take the start for the spectator’s own move.drawsare the recordedsenet.randomentries (numbers only, see Randomness below). -
vieweris a role name, ornullfor a spectator. -
stateis 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:
-
intandroll: the numbers drawn; -
selectandweighted: 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:
-
rollandintshow their numbers (“b2 — rolled 3, 5”); -
selectandweightedsay “drew one”; -
shufflesays “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 thesenet.randomresults 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,startGameandapplyMovewith the given draws, thengetPlayerStateandgetPlayerMovesfor 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) |