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.tsis the heart. Everything about what your game is lives there. -
A folder under
ui/is a UI module if it has amain.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:
-
playersis 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"]. -
tagssay 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. -
moveNotationandstateNotationare 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. -
paramsis for match settings — board size, a variant toggle, a time limit. Each one is{ key, label, type, default }withtypeone ofboolean,select,number, orstring(addoptionsfor select,min/maxfor number). Players pick them when they start a match, and they arrive in yournewGameas JSON. Leave it[]until you need it.A setting can depend on another:
enabledWhen: { key: "start", value: "choose" }makes it usable only while thestartsetting is"choose". Until then the form greys it out, and yournewGamegetsnullfor 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", not1. 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,parseMoveandserializeState. 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 itslogic.ts, under// --- Internal ---. (If your state is JSON,JSON.parseandJSON.stringifydo 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 insideeland drawstate. It may beasync: Senet waits for it before it sends your board anything else, so you can load what you need first. (Your board’s code may beasync; your rules can’t.) -
update(state: string): void— something changed; draw it again. -
onMove(cb: (move: string) => void): void— Senet hands youcb. When the player does something, callcb("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 ownlogic.ts, already loaded. This is the one to lean on: callsdk.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 playingsdk.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’sassets/folder. -
sdk.asset("pieces", "wN.svg")— the same, but from whichever asset pack the player picked (swappable piece sets, board textures).nullif 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.jsonfor Spanish, mapping each English phrase to its translation:{ "Your move": "Tu turno" }. Your game plays in one language: your MANIFEST’sdefaultLocaleif 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,indexToMoveandinstanceare yours:renderdraws the board,indexToMoveturns a clicked square into a move string, andinstanceis where your module keeps what it needs between calls (thesdk.logicyou were handed — or the wholesdk— and thecbfromonMove). tic-tac-toe’sui/default/main.tshas 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@mediaqueries 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",
},
-
moduleslists, for each UI module, the packs it can use on each axis. Putnullin 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. -
presetsare 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. -
defaultis 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 inmodulesis 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,
instanceandGLYPHare yours:instance.sdkis thesdkyourmountreceived (keep it — chess keeps it assdkRef), andGLYPHis 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.txtand 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 justapplyMovewrapped in atry. If your moves can’t be listed (seegetValidMovesin 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 throughsdk.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. (playeris missing only on the match’s start,seq0, which a game withstartGamegets 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, exportparseStateForUI, 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.tsas: a comment explaining your state and move format with examples →MANIFEST→ the required functions → the optional ones,getValidMovesincluded →parseStateForUI→ your private helpers. Anyone opening the file sees what it does before how. -
Put
exporton 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 missingdrawsshows 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
MANIFESTfield, every function Senet calls, thesdktoolkit 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.