← All guides

Make Your Own Game

You’ve got a game in your head. Maybe it’s a classic you want to see played online, maybe it’s a twist on one, maybe it’s something nobody has played yet. This guide takes it from idea to people playing it — step by step, starting from nothing.

You’ll write some TypeScript along the way: the rules, and what the board looks like. Everything else — players finding each other, turns, saving games, keeping cheaters honest — Senet does for you. And you don’t have to do it alone: the forum is where game-makers ask questions and share what they’re building.

This guide walks you through it. When you need the exact details later — every MANIFEST field, every function Senet calls, every limit — they’re in the Game reference.

This guide is new. If a step is confusing, missing, or just wrong, that’s a bug in the guide, not in you — tell us on the forum and we’ll fix it.


Step 1 — Start a project

Go to your projects, click New Project, give it a title, click Create. That’s the whole ceremony. The title becomes your game’s web address, so pick something you’ll be happy typing.

You can start up to five projects an hour, variants and themes included (Step 8), and deleting one doesn’t give its slot back. If Senet tells you to try again later, that’s why.

Your new project opens right away, and you’re editing. Your work saves itself as you type.

Here’s what you’re looking at:

logic.ts               The rules, and a short description of your game
about.md               The page players see before they hit Play
ui/
  default/             How your game looks
    main.ts            Draws the board, turns clicks into moves
    classic.css        A skin — colors and style

You’ll add to this as you go. A few things worth knowing now:

  • logic.ts is the heart. Everything about what your game is lives there.
  • A folder under ui/ is a UI module if it has a main.ts. That’s the only rule. You can have several — same game, different looks.
  • You’ll want about.md. It’s what players read on your game’s page. It’s written in Markdown, in the same editor as forum posts — a formatting toolbar, emoji, and a Preview tab that shows it the way your game’s page will.

The starter you were given is a real, playable game — trivial, but complete. Hit Test and play it before you change anything. You’ll see the whole loop work in thirty seconds, and then you’ll know exactly what you’re replacing.

Its screen is deliberately plain: the raw state, whose turn it is, a button for each legal move, and a box to type any move. That plainness is the point — it works with any rules, so when you replace logic.ts in Step 3 your game stays playable, by typing moves, long before you draw a board in Step 5.


Step 2 — Tell Senet about your game

Open logic.ts. At the top there’s a MANIFEST. It’s how your game introduces itself: what it’s called, who plays, what settings a match can have.

The snippets in this guide come from the reference games — complete games we keep up to date as examples. Their code is open to everyone: open tic-tac-toe and you can read every file. You can’t edit it, but you can copy from it, or use Create a Variant (Step 8) to get your own copy to play with.

// Adapted from tic-tac-toe/logic.ts — trimmed to the essentials.
export const MANIFEST = {
  title: "Tic-Tac-Toe",
  description: "Take turns placing X or O — first to three in a row wins!",
  tags: [],
  players: ["X", "O"],          // the roles, in seat order
  minPlayers: 2,
  maxPlayers: 2,
  moveNotation: "algebraic",     // a name for how you write moves
  stateNotation: "compact",      // a name for how you write the board
  params: [],                    // match settings — see below
  // appearance: { ... }         // optional looks & presets, Step 5
};

export function getManifest(): typeof MANIFEST { return MANIFEST; }

Change the title, the description, and the players to match your game. Keep getManifest() exactly as it is — it’s how Senet reads the rest.

Things that’ll save you a question later:

  • players is your cast, in seat order. players[0] sits in seat 1. Use names that mean something in your game — ["White", "Black"], ["Red", "Blue"], ["Fox", "Geese"].

  • tags say what kind of game it is — ["abstract", "two-player"], ["dice"]. Lowercase words, digits and single hyphens, up to 50 characters each; up to 20. A tag that breaks those rules, or anything past the 20th, is dropped without a warning. They’re part of your game’s files, so they go wherever the game goes.

  • moveNotation and stateNotation are just labels describing how you chose to write moves and boards. Use plain words, not your game’s name: "algebraic" means squares named by a column letter and a row number (a1, e4), and "compact" means the board packed into a short string.

  • params is for match settings — board size, a variant toggle, a time limit. Each one is { key, label, type, default } with type one of boolean, select, number, or string (add options for select, min/max for number). Players pick them when they start a match, and they arrive in your newGame as JSON. Leave it [] until you need it.

    A setting can depend on another: enabledWhen: { key: "start", value: "choose" } makes it usable only while the start setting is "choose". Until then the form greys it out, and your newGame gets null for it. That’s how a game offers “random, or pick one”: a select with Random and Choose, and a number that only counts with Choose. Every match lists its settings, on the game’s page and on the board’s page, so whoever joins sees them too.

Every MANIFEST field, with what it does, is in the MANIFEST section of the Game reference.


Step 3 — Teach it the rules

This is the part that’s yours. Before you write a line, three things that make everything else click:

  • Your game’s state is a string. Senet stores it and hands it back to you every time it needs something — you decide what’s in it. JSON is the easy choice ('{"board":[...],"turn":"X"}'). Some games use a shorter, hand-rolled notation instead: tic-tac-toe writes its whole board as "3/3/3 X" — three rows of three empty squares, and X to move. Either is fine.
  • A move is a string too. "a1" (place a mark on square a1), "e2e4" (a chess move: the piece on e2 goes to e4), '{"from":12,"to":28}' — your call. The board sends it, your rules interpret it.
  • You always speak in role names, never seat numbers. Your functions get "White", not 1. Senet handles who’s sitting where.

And a few rules for the code itself. Same inputs, same answer, every time. No network, no files, nothing tucked away in a global between calls. logic.ts is one file: it can’t import other files or packages, so everything it needs lives in it. And it’s synchronous: every function returns its answer directly — no timers, no async, never a promise. It runs in a sandbox, one call at a time. The one exception to the first rule is chance — dice, shuffles, a random setup — and for that there’s senet.random, whose draws Senet records with the move (see Games of chance, after Step 6).

Each call has five seconds. One that takes longer fails, just as if it had thrown. Replaying a whole finished match gets ten seconds for all its moves together. Your rules won’t need that long unless a loop never ends. The rest of the limits — move size, tags, uploads, draws — are in the Limits section of the Game reference.

Now the rules. Senet is going to ask your game six questions, over and over, for the whole life of a match. Each one is a function you export.

The snippets below call parseState, parseMove and serializeState. Those are yours, not Senet’s — the small helpers that turn your state string into something you can work with, and back again. Nothing provides them: if you copy a snippet, write the helpers too, or your game will fail to start with “parseState is not defined”. tic-tac-toe’s are at the bottom of its logic.ts, under // --- Internal ---. (If your state is JSON, JSON.parse and JSON.stringify do most of the work.)

“What does a brand-new game look like?”

// Adapted from tic-tac-toe/logic.ts (which reads the board from a constant).
export function newGame(_paramsJson: string): string {
  return "3/3/3 X"; // empty board, X to move
}

Return the starting state. paramsJson holds whatever match settings the players chose (from your MANIFEST.params), as a JSON string — parse it if you use it.

“Whose move is it?”

// Adapted from tic-tac-toe/logic.ts.
export function getActivePlayers(state: string): string[] {
  if (getWinner(state) !== null) return [];
  return [parseState(state).turn];
}

Return everyone who can act right now. Usually that’s one name. If your game has simultaneous turns, return several. When the game’s over, return none.

“Is this move allowed — and what happens?”

// Adapted from tic-tac-toe/logic.ts — the real one checks a few more cases.
export function applyMove(state: string, move: string, player: string): string {
  const { cells, turn } = parseState(state);
  if (player !== turn) throw new Error(`Not ${player}'s turn`);
  const idx = parseMove(move);
  if (idx < 0 || cells[idx] !== ".") throw new Error(`Invalid move: ${move}`);
  cells[idx] = turn;
  return serializeState(cells, turn === "X" ? "O" : "X");
}

The big one. Take the state, a move, and who made it. If it’s illegal, throw — Senet catches it and the move simply doesn’t happen, so you never have to worry about a bad move sneaking through. If it’s legal, return the new state.

“What does each player get to see?”

// From tic-tac-toe/logic.ts. For a game with secrets, see stratego/logic.ts.
export function getPlayerState(state: string, _player: string | null): string {
  return state; // everyone sees all
}

For most games: return the state unchanged, everyone sees everything. But if your game has secrets — a hand of cards, pieces only their owner can see — this is where you hide them. (Stratego is our example: each side’s pieces are face-down to the other player.) Return only what player is allowed to know. Senet calls it for every seat, and once more with null for spectators. Make sure it never throws: if it fails, that viewer may be shown the whole state.

When there’s nothing to hide, return exactly the string you were given — not an equivalent copy you rebuilt. Senet compares what each viewer gets with the full state, and a match where anyone saw something different counts as having secrets: its replays show each viewer only their own side, until its players choose to reveal theirs.

“Is it over? Who won?”

// Sketch — the real one is in tic-tac-toe/logic.ts.
export function getWinner(state: string): string | null {
  // ...check lines...
  if (someoneWon) return winningRole;
  if (boardFull)  return "draw";
  return null;
}

Return the winner’s role name, "draw", or null if play continues.

“What can this player do?”

// Adapted from tic-tac-toe/logic.ts.
export function getValidMoves(state: string, _player: string): string[] {
  const { cells } = parseState(state);
  if (getWinner(state) !== null) return [];
  const moves: string[] = [];
  for (let i = 0; i < 9; i++) {
    if (cells[i] === ".") moves.push(COLS[i % 3] + (Math.floor(i / 3) + 1));
  }
  return moves;
}

Every legal move for player, right now, written the way applyMove expects it. Treat this one as required. It’s how the board knows what to offer, and how a player finds out what they can do without learning your move format first — the starter’s plain view turns each one into a button. Every reference game has it.

The one exception is a game whose moves can’t be listed: too many to enumerate, or not a fixed set at all — a free-form placement anywhere on the board, a word typed by the player, a setup phase where you arrange every piece at once. Then skip it, and export isValidMove instead (Step 6), so the board can at least check a move before sending it.

That’s it — six questions, plus getManifest() from Step 2. Answer them and your game works — and you can play it right away.


Step 4 — Play it

Your rules are enough to play. You don’t need a board yet: the starter’s plain view — the one you tried in Step 1 — works with any rules. It shows the raw state, whose turn it is, a button for each move your getValidMoves returns, and a box to type any move. It isn’t pretty, and it isn’t meant to be: it’s there so you can check your rules before you spend time drawing anything.

Two buttons in the editor, and you’ll use both a lot.

Validate compiles your code and reads your MANIFEST. A green Valid! means your game loads — not that the rules are right. It checks that your game runs, not that it plays.

Test starts a real match and gives you a seat. (If your game has settings, you pick them first.) Click Join on each empty seat to take them all — the match starts once every seat is filled — and play against yourself, or click Invite to copy a link for a friend. Try the illegal move. Try the edge case. Try to win in a way you didn’t plan for. This is where you find out what your rules actually say, as opposed to what you meant.

If your code throws while a match starts, the message says where — “The game’s code failed to start — in getActivePlayers (logic.ts:46): parseState is not defined”. If a call throws while the plain view is drawing, it shows the error right there on the board.

When something’s off, click Back on the match page. You’ll land right back where you were in the editor — same file, same line. Fix it, and hit Test again.


Step 5 — Give it a face

Rules decide what’s true. A UI module decides what it looks like. Until now you’ve been playing through the starter’s plain view; this step replaces it with a real board.

A board is two files, and you replace both: ui/default/main.ts draws it and ui/default/classic.css styles it. The starter’s classic.css only styles the plain view, so a new main.ts with the old skin comes out unstyled — every square stacked in a single column.

main.ts exports four functions:

  • mount(el: HTMLElement, state: string, sdk): void — build your board inside el and draw state. It may be async: Senet waits for it before it sends your board anything else, so you can load what you need first. (Your board’s code may be async; your rules can’t.)
  • update(state: string): void — something changed; draw it again.
  • onMove(cb: (move: string) => void): void — Senet hands you cb. When the player does something, call cb("e2e4") and the move is on its way. This is how a click becomes a turn.
  • destroy(): void — clean up when the board goes away.

(Optionally also setSeat(seat: number | null), setStatus(status: string) and setResult(result: string) if you want to react to those. setStatus gets "replay" while your board is only being looked at — a replay, or a player viewing the board from another side — so you can stop offering moves; they wouldn’t be played anyway.)

The third argument to mount, sdk, is your toolkit:

  • sdk.logic — your own logic.ts, already loaded. This is the one to lean on: call sdk.logic.parseStateForUI(state) to get the board in a shape that’s easy to draw, instead of parsing the state string yourself twice. (Step 6 shows how to export that.)

  • sdk.players — your cast from the MANIFEST, in seat order. The person at seat N is playing sdk.players[N-1]. Use this rather than assuming seat 1 is always White.

  • sdk.asset("pieces/knight.svg") — the URL of a file in your module’s assets/ folder.

  • sdk.asset("pieces", "wN.svg") — the same, but from whichever asset pack the player picked (swappable piece sets, board textures). null if none. See Piece sets and presets below.

  • sdk.t("Your move") — translate a phrase. Returns it unchanged if no translation exists, so wrapping text costs nothing and pays off later. Write the phrase in English: it’s also the key a translation is looked up by. For a name or number in the middle, use a placeholder: sdk.t("{player} to move", { player: "White" }).

    A translation is a file per language, i18n/es.json for Spanish, mapping each English phrase to its translation: { "Your move": "Tu turno" }. Your game plays in one language: your MANIFEST’s defaultLocale if you have that file, else English, else the first file alphabetically. Players can’t pick a language yet, and the editor can’t add a translation file yet either — so for now, wrap your text, and it’ll be ready when they can.

// Sketch — the real one is in tic-tac-toe/ui/default/main.ts.
// `sdk` is the toolkit described above; an official type for it is on the way.
export function mount(el: HTMLElement, state: string, sdk): void {
  const logic = sdk?.logic;
  // ...build DOM, wire click → moveCallback(indexToMove(idx))...
  if (!logic) { el.innerHTML = "<p>Game logic unavailable.</p>"; return; }
  render(logic.parseStateForUI(state));
}
export function update(state: string): void { render(instance.logic.parseStateForUI(state)); }
export function onMove(cb: (move: string) => void): void { instance.moveCallback = cb; }
export function destroy(): void { /* remove listeners, clear el */ }

Like the helpers in Step 3, render, indexToMove and instance are yours: render draws the board, indexToMove turns a clicked square into a move string, and instance is where your module keeps what it needs between calls (the sdk.logic you were handed — or the whole sdk — and the cb from onMove). tic-tac-toe’s ui/default/main.ts has all three under // --- Internal ---.

The whole toolkit is in The sdk object section of the Game reference, and translations in its Translations section.

Your board runs inside its own sandboxed frame, sealed off from the rest of the page. That’s a feature, with one consequence you need to know about:

Sizing: use @container, not @media. Your board is sized by the frame around it, not the browser window, so CSS @media queries never match — and they fail silently, with no error to chase. Write container queries instead:

@container (min-width: 480px) { .board { gap: 12px } }

And give your root element width: 100%. An SVG board without it will render at its natural size instead of filling the space you’ve got.

Skins live next to main.ts as ui/default/{name}.css — classic.css, dark.css, neon.css. The filename is the skin’s name, so don’t call one default.css. Players can switch between them mid-game.

For anything that isn’t part of your game’s identity — text, panels, borders — use the platform’s theme colors, so your board reads in both light and dark: color: rgb(var(--c-primary)), background: rgb(var(--c-surface)), with --c-muted, --c-edge, --c-danger-text and friends alongside. Keep your own colors for what is your game (a chessboard’s green and cream squares): those should look the same in every theme.

Libraries. Some boards need one: a 3D board needs a 3D library. Senet keeps a small catalog that its admins look after, so games share one reviewed copy. To use one, name it and its version in your MANIFEST, and import it by that name in your board:

libs: { three: "0.123.0" },       // in MANIFEST: three.js, which connect-four's 3D board uses

import * as THREE from "three";   // in your ui/{module}/main.ts

Only your board can use libraries; logic.ts can’t import anything. A version that names a library the catalog doesn’t have can’t be approved, so if you need one that isn’t there, ask on the forum. The rules are in the Platform libraries section of the Game reference.

Piece sets and presets

Some players want classic Staunton pieces, others flat shapes, others no pictures at all. You don’t need a second UI module for each: draw the board once, and let the pictures be swappable. A swappable set of pictures is an asset pack. Chess is the example here — one UI module, two piece sets, and plain glyphs for players who pick neither.

Where packs live. At the top of your project, not inside a UI module, so one set works with every module you have:

asset-packs/
  pieces/              the axis — what varies
    rhosgfx/           a pack — one version of it
      wK.svg  wQ.svg  wR.svg  wB.svg  wN.svg  wP.svg
      bK.svg  bQ.svg  bR.svg  bB.svg  bN.svg  bP.svg
    silhouette/
      wK.svg  …        the same names, different drawings

The axis is the thing that changes (pieces, board), and each pack is one version of it. You choose the file names — your UI asks for them — and every pack on an axis uses the same ones: a file missing from one pack shows up as a broken picture when that pack is picked. One file per piece is the easy path: most piece sets you’ll find come that way.

Making one in the editor: at the bottom of the file tree there’s an asset-packs/ folder. Hover it and click +, type the axis and a name for the pack (lowercase letters, numbers, - and _), drop in your files, and click Upload. That creates the pack. Later, the ↑ on a pack adds more files to it, and + on an axis starts another pack on that axis.

Tell Senet which packs a module works with, in MANIFEST.appearance:

// From chess/logic.ts.
appearance: {
  modules: {
    default: { pieces: [null, "rhosgfx", "silhouette"] }, // null: no pack → glyphs
  },
  presets: {
    modern:     { module: "default", skin: "classic", pieces: "rhosgfx" },
    silhouette: { module: "default", skin: "classic", pieces: "silhouette" },
    classic:    { module: "default", skin: "classic", pieces: null },
  },
  default: "modern",
},
  • modules lists, for each UI module, the packs it can use on each axis. Put null in the list if the module also works with no pack (chess draws Unicode glyphs then); leave it out if your board needs pictures. A pack that isn’t listed here is never offered to players, even though its files are in your project.
  • presets are named looks: a module, a skin, and a pack for each axis. Players see them first, as buttons in the Appearance panel, with Customize underneath for picking each part on its own. They see the names exactly as you write them — preset and pack names alike — so pick words a player understands.
  • default is the preset a new player starts with. It also fills in for a preset that leaves an axis out: that preset gets the default preset’s pack, and if the default doesn’t choose one either, the first entry in modules is used.

In your UI, ask for a picture, and have a plan for null — that’s what you get when the player chose no pack:

// Adapted from chess/ui/default/main.ts. `piece` is a letter: "K" is White's
// king, "k" Black's.
function pieceElement(piece: string): HTMLElement {
  const white = piece === piece.toUpperCase();
  const src = instance.sdk.asset("pieces", `${white ? "w" : "b"}${piece.toUpperCase()}.svg`);
  if (src) {
    const img = document.createElement("img");
    img.className = "piece-img"; // sized in your skin — see below
    img.src = src;
    return img;
  }
  const span = document.createElement("span"); // no pack: draw a glyph instead
  // "\uFE0E" asks for the plain glyph; some systems draw ♟ as a color emoji.
  span.textContent = GLYPH[piece.toLowerCase()] + "\uFE0E";
  return span;
}

As in the sketch above, instance and GLYPH are yours: instance.sdk is the sdk your mount received (keep it — chess keeps it as sdkRef), and GLYPH is your own letter-to-symbol map, like { k: "♚", q: "♛", … }.

Give the pictures a size in your skin. Most piece-set SVGs don’t say how big they are, so an <img> with no size can come out tiny, or not at all. For the snippet above, .piece-img { width: 88%; height: 88%; } makes each piece fill most of its square — chess’s classic.css does the same for its .chess-piece-img.

Players choose. On the board’s page, the Appearance panel lets each player pick one of your presets, or, under Customize, a UI module, one of its skins, and a pack on each axis (a game without presets shows those choices directly). Senet remembers the choice, game by game, for anyone with an account, guests included. So each module, skin and pack has to stand on its own: a player can put any of a module’s skins together with any pack you list for it, not only the combinations your presets name. If a remembered choice is gone (you removed a pack, say), they get your default preset’s, or else the module’s first in modules.

When a player switches packs, your board is mounted again from scratch, so you never have to swap pictures on a board that’s already drawn.

Using someone else’s art? Check its license before anything else — “free to download” isn’t the same as “free to use”:

  • Public domain (CC0): use it, nothing else needed. Both of chess’s sets are CC0.
  • MIT, BSD, Apache: allowed, as long as the license travels with the files. Save its text — copyright line included — as LICENSE.txt and upload it into the pack with the pictures. Apache also asks you to say what you changed.
  • GPL: the same, and your edited versions of those pictures stay GPL.
  • CC BY: allowed, with credit players can see: the artist, the license (with a link), where the set came from, and what you changed, if anything.
  • CC BY-SA: the same, and your edited versions of those pictures stay CC BY-SA.
  • GFDL: its full text has to travel with the files; if the artist also offers another license (many do), pick that one instead.
  • Non-commercial (NC), “no derivatives” (ND), “personal use only”, or no license at all: don’t use them.
  • Art from commercial sites (chess.com, for example) isn’t licensed for reuse, however easy it is to save.

Senet doesn’t show credits by itself yet, so for anything but CC0 add a Credits section to your about.md — it’s on your game’s page — naming each set, its artist, its license, where it came from, and what you changed, if anything. Together with a LICENSE.txt in the pack, that covers every license above you’re allowed to use.


Step 6 — Make it good

Your game works. These aren’t required, but the games people come back to have them.

  • isValidMove(state: string, move: string, player: string): boolean — a quick yes/no that doesn’t throw, for checking a move before going ahead with it. Often just applyMove wrapped in a try. If your moves can’t be listed (see getValidMoves in Step 3), this is how the board still helps.

  • parseStateForUI(state: string) — turn your state string into something easy to draw, and export it. Your UI calls it through sdk.logic. This is the habit that keeps your rules and your board agreeing about what’s on it:

    // tic-tac-toe's view; yours is whatever shape your board is easiest to draw from.
    export function parseStateForUI(state: string): { cells: string[]; turn: string } {
      return parseState(state); // your helper from Step 3
    }
  • getPlayerMoves(moves: { seq: number; player?: string; move: string; draws: object[] }[], viewer: string | null, state: string): (string | { move: string; draws: string } | null)[] — only for games with secrets. (player is missing only on the match’s start, seq 0, which a game with startGame gets first — see Games of chance.) Lets you hide entries in the move history from players who shouldn’t see them — say, a game where each side secretly places its pieces before play starts. It also decides how a move’s dice and draws are shown (see Games of chance, next).

A few habits the reference games share, which you’ll be glad you copied:

  • Parse your state in exactly one place. Write the parser once in logic.ts, export parseStateForUI, and never re-read the board format anywhere else. Rules and board drifting apart is the classic bug; this closes the door on it.
  • Public things first. Order logic.ts as: a comment explaining your state and move format with examples → MANIFEST → the required functions → the optional ones, getValidMoves included → parseStateForUI → your private helpers. Anyone opening the file sees what it does before how.
  • Put export on the declaration itself — export function, not a list of names at the bottom that quietly falls out of date.

Games of chance

Dice, a shuffled deck, a random setup: for these, your logic has a global senet.random. It works everywhere your logic runs — newGame, applyMove, every other function, and in the browser when your board uses sdk.logic.

Call Returns
senet.random.int(1, 6) a whole number from 1 to 6, both included
senet.random.roll("2d6") one number per die, like [3, 5] — add them up yourself if you want the total. "d20" is a single die
senet.random.select(list) one element of list, each equally likely
senet.random.shuffle(list) a shuffled copy of list (the list itself isn’t changed)
senet.random.weighted([["gold", 1], ["silver", 3]]) one of the items, in proportion to its weight — here silver three times as often as gold

A bad argument — an empty list, "2x6" — throws an error starting with senet.random. and the function’s name. In TypeScript, declare it once in logic.ts so the editor knows its shape:

declare const senet: {
  random: {
    int(min: number, max: number): number;
    roll(dice: string): number[];
    select<T>(list: T[]): T;
    shuffle<T>(list: T[]): T[];
    weighted<T>(pairs: [T, number][]): T;
  };
};

Senet records what your game draws. Everything drawn during newGame, and during a move applyMove accepts, is saved with the match, in order. The move history shows what each move drew next to it — “rolled 3, 5”. A move that throws is thrown away along with whatever it drew. Draws anywhere else (getValidMoves, getPlayerState, your board) aren’t recorded, so use them for flourishes only, never for anything that changes the game. Use senet.random rather than Math.random(): only senet.random is recorded, so a replay of a match that used Math.random() comes out different.

Start clean

Before a match starts, the waiting room shows the position newGame returned — so a setup you randomize there is visible before anyone has taken a seat. We recommend a clean start instead: newGame returns a neutral state, and you export startGame(state, paramsJson), which does the randomizing. Senet runs it by itself the moment the match starts — once the seats are filled, before anyone moves — and its result is the state the match is played from. It isn’t a move: nobody makes it, and nobody waits on a button. That’s how Onitama deals its cards:

// Adapted from onitama/logic.ts.
export function newGame(_paramsJson: string): string {
  return serializeState({ board: START, turn: "Red", red: [], blue: [], spare: null });
}

export function startGame(state: string, _paramsJson: string): string {
  const cards = senet.random.shuffle(ALL_CARDS);
  return serializeState({ ...parseState(state), red: cards.slice(0, 2), blue: cards.slice(2, 4), spare: cards[4] });
}

startGame gets the match settings too, the same JSON newGame gets. Chess960 splits the work: a position a player chose is set up right in newGame — there’s nothing to hide — and startGame draws one of the 960 only when they left it at Random. Until the start, answer the neutral state sensibly: nobody is active, and no move is allowed.

What startGame draws is recorded like a move’s, and the move list opens with it: “Match started — shuffled”. If startGame throws, the match doesn’t start — it stays in the waiting room.

Randomizing in newGame still works, if you’d rather players see the start before they sit down. Senet records those draws too, and the match starts from exactly the position the waiting room showed. But know what that means: the start is decided before anyone plays. Each seated player sees their part of it in the waiting room (through getPlayerState) — a dealt hand included — and a player who doesn’t like it can leave and start another match until they do. If your start must stay secret until play begins, or mustn’t be re-rolled that way, randomize in startGame instead.

Dice: their own move, or part of one

That’s your call, and it shapes how your game feels.

  • Their own move, as in backgammon: the player rolls, everyone sees the dice, then the player chooses a move with them. The roll is a move ("roll") that stores the dice in the state; the next move reads them from there.

    if (move === "roll") return serializeState({ ...s, dice: senet.random.roll("2d6") });
  • Inside a move, as in a Risk attack: one move rolls both sides’ dice and applies the result on the spot.

    const attack = senet.random.roll("3d6").sort((a, b) => b - a);
    const defense = senet.random.roll("2d6").sort((a, b) => b - a);
    // compare them pairwise, remove the losses...

Don’t let a roll decide whether a move is allowed

A move that throws takes its draws with it, and the player can simply send it again. So if applyMove rolls and then throws on a bad roll — “you may only leave the jail on a 6” — a player can keep trying until the 6 comes up. Make the roll stick instead: make it its own move, or accept the move either way and let the roll decide what happens (the turn passes, the piece stays in jail).

Secret draws

A card dealt face-down is hidden the usual way: getPlayerState leaves it out for everyone but its owner. The move history needs the same care, and by default it’s careful for you: int and roll show their numbers, but select and weighted only say “drew one”, and shuffle says “shuffled” — never which item came out. A move that drew several times shows each draw in order: “b2 — rolled 3, 5; drew one”.

To show more, or less, export getPlayerMoves (Step 6). Each move it gets carries a draws list, what that move drew, in order — say [{ fn: "roll", args: ["1d6"], result: [4] }]. If your game has startGame, the list opens with the start: { seq: 0, move: "", draws }, with no player at all. So check m.seq === 0 before you read m.move or compare m.player, and compare with === and !==: m.player != viewer would take the start for a spectator’s own, since a spectator’s viewer is null and undefined == null. Senet always labels that row Match started; you choose only its draw text, or hide it. For each move, return:

  • the move as a string, with the default text for its draws;
  • { move, draws: "your text" }, to write that text yourself (up to 1,000 bytes). In this form the text is all yours: an empty or missing draws shows the move with no draw text at all;
  • null, to hide the move altogether.

If getPlayerMoves throws, or returns a list of a different length or an entry of another shape, those moves show as “hidden move” — never the move itself, since your game may be hiding it. So test it with every kind of move your game has. A move can be at most 4 KB, and the history shows up to 1,000 bytes of a move’s text.

It isn’t always handed the whole history, either. When a page loads a match, it gets every move at once; during play, each new move comes on its own — a list of one, with the state right after that move — and a replay asks again at every step. So answer each entry from the entry itself, the viewer and the state, never from where it sits in the list or from the moves before it.

// A secret roll: its owner sees the number, everyone else only that they rolled.
export function getPlayerMoves(
  moves: { seq: number; player?: string; move: string; draws: object[] }[],
  viewer: string | null,
  _state: string,
): (string | { move: string; draws: string } | null)[] {
  return moves.map((m) =>
    m.seq !== 0 && m.move === "secret-roll" && m.player !== viewer ? { move: m.move, draws: "rolled" } : m.move
  );
}

Step 7 — Share it

When it plays the way you want, Submit it. A platform moderator takes a look — every game in the catalog gets one — and the first approval publishes your game: it appears in the catalog, anyone can start a match, and you can invite people with a link. When you start a match you choose who can join: anyone (it’s listed on your game’s page), or only the people you send the invite link to.

Every update goes the same way — submit, a moderator approves — and each approval is a new version. Matches already in progress keep playing the version they started on, so you can improve your game freely — nobody mid-game will have the rules change under them.

Every finished match can be replayed. Open a match that’s over and you can step through it move by move. Paste a match’s link into a ```match block in a forum post or comment — or copy the match with Export PGN, ready to paste — and readers get a board they can step through too. Your game needs nothing extra for this: Senet plays your rules back with the draws it recorded — which is why they must give the same answer every time. A ```match block works in your about.md too, if you want an example game on your page.


Step 8 — Remix it

This is the part Senet exists for. On any game’s page there’s a Create a Variant button. Click it, give your variant a title, click Create, and you get your own full copy — rules, board and tags — and its page links back to the game it came from. That link is one line in your copy’s MANIFEST, which Senet adds for you: variantOf: "chess", the name of the game it came from. Keep it: it’s how the link travels with your game’s files.

Change only what’s different. A new rule in applyMove, an extra setting in MANIFEST.params, a different piece set — and you’ve got chess with the back row shuffled, or chess where reaching the middle of the board also wins, still pointing back at where it came from.

A variant is just a game. Same six questions, same steps. It’s the shortcut that makes “what if…” cheap enough to actually try.


You’re not alone

  • Stuck? Ask on the forum. If a step here was the problem, say so — that’s how this guide gets better.
  • Need the exact rules? The Game reference lists every MANIFEST field, every function Senet calls, the sdk toolkit and every limit.
  • Want to see it done right? The reference games are the real thing, kept current, and anyone can read their code: tic-tac-toe is the smallest complete game, stratego shows a game where players can’t see everything, and connect-four draws a 3D board with a graphics library the platform provides.

Now go make something people will play.