What this is
duel-game-core (https://github.com/research-ag/duel-core) is two
rules-agnostic packages — a Motoko mops package (session engine) and an
npm package (browser client) — implementing everything a 2-player game
needs except the game itself: a multi-table lobby (open or access-code
protected tables), seating, round submission, debrief, idle takeover,
rematch, claim-win, session identity, the polling transport, a headless client
holding all of that browser-side, and default screens over it that a
game can replace one at a time or entirely.
The user gives you rules, nothing else; you produce the whole game. Make the State/Action/rendering design calls yourself. Ask only when the rules are genuinely ambiguous about game logic (a win condition, a limit), never about engine mechanics (seating, rematch, timeouts — already handled).
A finished game is six pieces of game-specific code:
src/<Rules>.mo— a pure module implementingTP.Spec<State, Action>.src/Host.mo— a thin host actor; copy the template.test/RulesUnit.test.mo— unit checks for yourvalidate/resolve(the engine has its own suite).frontend/src/<game>-plugin.js— aGamePlugin.frontend/src/index.html+app.js+style.css— copy the templates.icp.yaml,publish_wasm.sh,mops.toml,frontend/package.json,frontend/.npmrc,frontend/build.js— copy the templates, fill in names.
Every template lives in templates/; read each one right before adapting
it. references/ covers five situations the templates don't:
alternating-turn-games.md, canister-player-bots.md, rich-ui.md,
testing-deep-dive.md, and frontend-for-existing-game.md (a new
frontend for a game someone else already deployed, from its canister id
alone) — read them only when you hit that situation.
Step 1 — Get the packages into the project
You are very likely in a new repo, separate from research-ag/duel-core.
If there is no mops.toml, create one from templates/mops.toml.template
first; likewise frontend/package.json from
templates/package.json.template before any npm command.
mops add duel-game-core
If that fails (not on mops.one yet), clone the framework and use a path dependency, as its own examples do:
git clone https://github.com/research-ag/duel-core.git ../duel-core
[dependencies]
duel-game-core = "../duel-core/backend"
core = "2.6.1"
Frontend: npm view duel-game-core version. If it resolves, keep
"duel-game-core": "*" and npm install duel-game-core --save-exact.
Otherwise set it to "file:../duel-core/frontend" (the template .npmrc's
install-links=true is required for a file: dependency) and build the
clone's frontend first: cd ../duel-core/frontend && npm install --legacy-peer-deps && npm run build — the package ships from dist/,
which a fresh clone lacks. A file: dependency is a copy: after a git pull of the clone, rebuild it and reinstall here (see the framework's
root CLAUDE.md, "After touching anything under frontend/").
The installed package ships both READMEs:
.mops/duel-game-core@<version>/README.md (or
../duel-core/backend/README.md) and
node_modules/duel-game-core/README.md.
Step 2 — Turn the rules into State / Action
Read the rules fully, then answer:
- Do both seats act at once, or take turns?
#simultaneous(both submit;resolve : (State, Action, Action) -> ...) is whattemplates/Rules.mo.templateis written for.#alternating(the on-turn seat submits;resolve : (State, Seat, Action) -> ...; the engine tracks whose turn it is, soStateneeds no turn flag) — readreferences/alternating-turn-games.md;examples/checkersin the framework repo is the worked example. Actionis a raw decision, never a derived value. A damage number or result that is a function ofStateplus the choice belongs inresolve, notAction:{ #attack; #defend }, not{ #attack : { damage : Nat } }. Client input is untrusted. This is the single most common mistake when porting an existing game.- What persists in
Stateacross rounds? Only what future rounds need, plus whatever the client needs to show the opponent's last move (Step 6). A#simultaneousgame whose state doesn't reveal both picks (rock-paper-scissors) carries alastRound : ?Round; a board game can leave it out, since the plugin can diff two boards. - What ends the game? Map every win/lose/draw to
resolve'sverdict : ?TP.Verdict(#p1Wins/#p2Wins/#draw).nullmeans "continue", not "draw". - Is a number a player choice or fixed data? A fixed board/deck is a
let, not a field. - Two Motoko traps: a module-level
letmust be static (no function calls —M0014);Natsubtraction traps on underflow, so guard everya - bwitha >= b. - Porting an existing game: document every deliberate simplification
in the rules module's doc header, and match ported formulas exactly
(Motoko
Floatand JSnumberare both IEEE754 doubles, so client and server agree bit-for-bit) — note the source file/line per piece. - More than one rules variant, picked by a table's creator? (see the
backend README's "Table variants")
- Same fields, different legality → flat
Action/State, avariantfield onState, and a branch invalidate.init(raw : Text)parses the stored text into a closed type with a safe default for""/unrecognized. Buildresolveagainst the variant with the most legal moves (examples/rock-paper-scissors); branchresolveons.variantonly when a variant changes what a move does (examples/chopsticks). - Different fields entirely → tag
Actioninto a union over each variant's payload, dispatched through a variant-keyed lookup (see the template's commented-out alternative). Still one Candid type; the wire shape stays opaqueText. - No variants:
initstill takes theText, and ignores it.
- Same fields, different legality → flat
Once you can state in a sentence each what State holds, what
Action's variants are, what validate rejects, and what resolve
computes, write the module.
Worked mini example
"Rock-paper-scissors. Each round both pick; the usual beats-relationship decides the round. First to 3 round wins takes the match; a tie scores nobody."
Action = { #rock; #paper; #scissors }.State = { p1Score : Nat; p2Score : Nat }.validatereturnsnullunconditionally.resolvedecides the round, bumps a score, returns?#p1Wins/?#p2Winsat 3, elsenull.
Step 3 — Write the Rules module
From templates/Rules.mo.template, write src/<YourGame>Rules.mo:
__RULES_MODULE__,__GAME_TITLE__,__P1_NAME__,__P2_NAME__.- Replace the "Rules" doc block with the actual rules, one line per mechanic — the most useful comment in the game.
- Fill in
Action,Side,State,freshSide,validate,resolve.inittakes the table's variant text (""for a game without variants; the parameter is then unused). - Keep everything pure: no
Time, no mutation, no storage;{ me with ... }, never in-place.Specis passed fresh on every call and never stored. - Write
SEMANTICSlast, once the types and rules are settled. The host serves it atGET /semantics, and it is all a third party gets to build their own frontend for your game:Actiontravels inside an opaque blob, so this text is its only public description. Give the exact Candid ofStateandAction(Natisnat,?Tisopt T,[T]isvec T, a tuple is a positional record), what each field means, every rejection invalidate, every ending, and anything a UI must compute itself. The backend README's "Semantics over HTTP" has the section-by-section contract. Change it wheneverState,Action,validateorresolvechange.
Step 4 — Write the host actor
From templates/Host.mo.template, write src/Host.mo, filling in only
__RULES_MODULE__, __IDLE_TIMEOUT_NS__ (90_000_000_000 = 90s: how
long an abandoned game sits before a third party may take it over, and
how long a waiting seat whose player has disconnected is kept), and
__CLAIM_TIMEOUT_NS__ (60_000_000_000 = 60s: how long your submitted
move may sit against the opponent's silence before you may claim the
win). Both go into registry.setTimeouts on the line after
Registry.new(): a stable registry runs its initializer only on first
install, while setTimeouts runs on every upgrade. Keep
idleTimeoutNs comfortably above claimTimeoutNs (at least 15s of
margin) — an idle game can otherwise be taken over by a third party
before its own claim window even opens. Change nothing else. Do not add plain Candid methods for
createTable/joinTable/submit/rematch/leave/reset/claimWin/
ackEnded — mo:duel-game-core/transport (Transport.attach + ActorMixin) is the
only mutation path; a direct update call reopens the ordering race it
closes. status stays a plain query. The template wires a
Registry, so the game gets a multi-table lobby for free, and
HttpActorMixin, whose one route answers GET /semantics with
Rules.SEMANTICS; data a client needs but State does not carry (a
fixed map, a deck list) goes in as a further (path, () -> Text) route.
The same mixin also serves the canister's own module at GET /wasm and
takes it in through the controllers-only wasm_upload_* methods that
publish_wasm.sh calls at deploy time (Step 7); nothing to wire.
Claim a win. The generic #inGame screen renders a "Claim the win"
button with its own countdown once claimWinAvailable turns true, and
shows the deciding opponent the mirror warning. Nothing in GamePlugin
is involved. In an #alternating game only the seat NOT on turn ever
sees the control.
Metrics (optional). Only if asked for observability: mops add promtracker and
import PT "mo:promtracker";
persistent actor {
let pt = PT.Tracker.new();
transient let renderer = PT.Renderer();
renderer.addValue(PT.allSystemMetrics);
renderer.addValue(pt.toValue());
let registry : TP.Registry<Rules.State, Rules.Action> = Registry.new();
registry.setTimeouts(__IDLE_TIMEOUT_NS__, __CLAIM_TIMEOUT_NS__);
registry.attachMetrics(pt); // games_started / active_games / rounds_per_game / matchmaking_wait_seconds
// ...status/Transport.attach/ActorMixin as in the template...
include HttpActorMixin([
("/semantics", func() : Text = Rules.SEMANTICS),
("/metrics", renderer.renderExposition), // one more route on the template's list
]);
};
See the backend README's "Metrics" and examples/racing/src/Host.mo.
Canister players (optional). Only if asked for a bot/AI opponent.
Nothing to add to mops.toml. Extend Host.mo:
import Principal "mo:core/Principal";
import Timer "mo:core/Timer";
import CanisterPlayers "mo:duel-game-core/canister_players";
import CanisterPlayersActorMixin "mo:duel-game-core/canister_players_actor_mixin";
import BotIface "BotIface"; // make_move : (TP.MoveRequest<Rules.State, Rules.Action>) -> async Rules.Action
persistent actor {
// ...registry/status; Transport.attach's onSettled argument becomes ?settle...
transient var settleTable : ?((Int, TP.TableId) -> async* ()) = null;
transient let settle = func(now : Int, id : TP.TableId) : async* () {
switch (settleTable) { case (?f) await* f(now, id); case null {} };
};
transient let cpAttached = CanisterPlayers.attach<Rules.State, Rules.Action>(
Rules.spec(),
registry,
attached.afterMutation,
true, // afterMutationSettles: `attached` runs `settle` via onSettled
func(session, req, k) : async* () {
let p = CanisterPlayers.principalOfCanisterSession(session);
let bot : BotIface.CanisterPlayer = actor (p.toText());
try { await* k(?(await bot.make_move(req))) } catch (_) { await* k(null) };
},
func(id : TP.TableId, secs : Nat) : async* () {
ignore Timer.setTimer<system>(#seconds secs, func() : async () { await* settle(Time.now(), id) });
},
);
settleTable := ?cpAttached.settle;
let botDirectory = CanisterPlayers.newBotDirectory();
include CanisterPlayersActorMixin(cpAttached, botDirectory, null); // ?leaderboard once wired
transient let combinedSweep = func(now : Int) : async* () {
await* attached.sweep(now);
await* cpAttached.sweep(now);
};
include ActorMixin<system>(attached.endpoint, combinedSweep);
};
Add src/BotIface.mo (one method, make_move) and, if writing the bot,
a bot/Bot.mo/bot/BotLogic.mo pair like examples/checkers/bot/. The
backend README's "Canister players" has the full design. MoveRequest
also carries turn/gen, opponent, opponentLastMove, and
lastRoundDurationNs; a bot that only reads game/seat/turn is a
pure query, which is the right default; a bot that needs randomness
stays one by seeding from Time.now(). Read
references/canister-player-bots.md before writing a bot that picks at
random or remembers anything — the latter makes make_move no longer a
query.
A challengeable bot self-registers once, by hand, after both canisters are deployed:
public shared ({ caller }) func register(host : Principal.Principal, name : Text) : async () {
assert Principal.isController(caller);
let h : actor { register_bot : (Text, [Text]) -> async () } = actor (host.toText());
await h.register_bot(name, BotLogic.COMPLEXITIES);
};
BotLogic.COMPLEXITIES is the bot's own list of ways to play
("Easy"/"Hard", ...) — [] for one way, listed as "Default". A
challenger picks one per game; every make_move carries it as
req.complexity, and each is rated separately. Trigger with
icp canister call bot register '(principal "<host-canister-id>", "MyBot")'.
The bot then appears in every player's "🤖 Bots" dialog and the
leaderboard's Challenge buttons (frontend/README.md, "Bot registry"),
and a Rematch against it re-invites it automatically.
Leaderboard (optional). Only if asked for rankings. A win/lose/draw
game wants ELO (#claimed/#aborted count like a win); another metric
is tracked directly, converted to higher-is-better if needed. Nothing to
add to mops.toml:
import Leaderboard "mo:duel-game-core/leaderboard";
import LeaderboardActorMixin "mo:duel-game-core/leaderboard_actor_mixin";
import Elo "mo:duel-game-core/elo";
persistent actor {
let STARTING_ELO : Int = 1200; // your call; elo.mo has no opinion
let leaderboard = Leaderboard.new(50, STARTING_ELO); // keep 50, show 25
func onGameEnded(_id : TP.TableId, p1 : TP.SessionId, p2 : TP.SessionId, d : TP.Debrief<Rules.State>) {
let outcome : Elo.Outcome = switch (d.end) {
case (#finished(#p1Wins)) #aWins;
case (#finished(#p2Wins)) #bWins;
case (#finished(#draw)) #draw;
case (#claimed(#p1)) #aWins;
case (#claimed(#p2)) #bWins;
case (#aborted(#p1)) #bWins;
case (#aborted(#p2)) #aWins;
};
let (k1, k2) = (Transport.playerKey(p1), Transport.playerKey(p2));
let (r1, r2) = Elo.update(Leaderboard.scoreOf(leaderboard, k1), Leaderboard.scoreOf(leaderboard, k2), outcome, 32);
let now = Time.now();
Leaderboard.setScore(leaderboard, k1, r1, now);
Leaderboard.setScore(leaderboard, k2, r2, now);
};
// ...Transport.attach's onGameEnded argument becomes ?onGameEnded...
include LeaderboardActorMixin(leaderboard, 25);
};
For a metric like a best time, first check whether game state already
tells you (examples/racing derives the lap time from Debrief.turns
and the final state). Only when real elapsed time is genuinely needed,
wire onGameStarted (the third null after the codec):
let leaderboard = Leaderboard.new(50, 0); // defaultScore unused by this shape
func scoreFromYourMetric(raw : Int) : Int = Int.max(0, 3_600_000 - raw); // lower-is-better -> higher
let matchStarts = Map.empty<TP.TableId, Int>();
func onGameStarted(id : TP.TableId, _p1 : TP.SessionId, _p2 : TP.SessionId) {
matchStarts.add(id, Time.now());
};
func onGameEnded(id : TP.TableId, p1 : TP.SessionId, p2 : TP.SessionId, d : TP.Debrief<Rules.State>) {
// read d.end/d.finalGame, elapsed = Time.now() - matchStarts.get(id),
// Leaderboard.recordIfBetter(leaderboard, Transport.playerKey(winner), scoreFromYourMetric(raw), Time.now())
matchStarts.remove(id);
};
With canister players, special-case
CanisterPlayers.leaderboardKeyOfSession(sid) before Transport.playerKey, so
a bot's rating accumulates across tables, per complexity. On the
frontend, call actor.get_leaderboard() (already declared by idl.js)
and renderLeaderboard(entries, plugin, { yourSid: session.sid, botNames })
(botNames from actor.list_bots().catch(() => [])). Use the examples'
panel shape: an icon-only 🏆 toggle first in .session opening a
full-page overlay (#leaderboard-panel), never an inline panel a status
view could clobber. Add formatScore to the plugin only for a converted
metric.
Step 5 — Write the rules unit tests
From templates/RulesUnit.test.mo.template, write test/RulesUnit.test.mo.
Cover at minimum:
init("")is the starting position andspec()hands out your own functions (section 1 of the template). With variants, alsoinitper key plus one unrecognized string.- One
validatecase per way a move can be illegal; every legal case accepted. - One
resolvecase per win/lose/draw path, plus scoring edge cases.
Do not test join/leave/rematch/takeover — the engine's suite does.
Synthetic states are sufficient and far faster than driving the engine.
If a genuine end-to-end test needs a game that takes many rounds to
finish, read references/testing-deep-dive.md first.
moc -r --package core <path-to-core/src> --package duel-game-core <path-to-backend/src> test/RulesUnit.test.mo
# or, with mops.toml listing both: mops test
Step 6 — Write the frontend GamePlugin
From templates/plugin.js.template, write frontend/src/<game>-plugin.js:
idlTypes({ IDL })must match yourState/ActionCandid shape exactly, and only those (the engine's own types are already known).Nat/Intdecode tobigint; a tuple decodes to an array.seatLabel(seat).renderBoard(gameState, mySeat, oppSeat)— an HTML string, valid for a live game and a debrief.esc()any text from game state. It must show the opponent's most recent move, live and in the debrief: a highlighted cell, a piece's origin and landing, captured pieces as ghosts, or both picks of the last round. WithoutlastRoundinState, find the move by diffing the board against the previous one the plugin saw (examples/tic-tac-toe,examples/checkers).renderActions(gameState, mySeat)— one<button>per action viaactionAttr(). Disable via a cosmeticlegal()mirror ofvalidate; the engine still runs the real one.applyLocal(gameState, mySeat, move)— the state with the player's own move applied, a cosmetic mirror ofresolve(for#alternating, the board after the move; for#simultaneous, usuallygameStateunchanged). The default UI draws it the moment the move is sent, so a player never stares at an unchanged board while the submit, or a bot's reply, is in flight. Returnnullonly for a move you can't predict. Write it for every game.formatScore(score)— only for a converted-metric leaderboard.variantChoices()/formatVariant(variant)— only with variants; the first choice is the default, eachkeyis whatinitreceives.
Copy index.html.template, app.js.template, style.css.template into
frontend/src/, filling __GAME_TITLE__, __PLUGIN_FILE__,
__IDLE_TIMEOUT_SECONDS__. style.css may stay empty but must exist
(build.js copies it). app.js resolves a non-spoofable anonymous
identity, builds the actor, connects the transport, and calls
start({ plugin, ws, session }); esbuild inlines every dependency, so
no CDN or import map. For Internet Identity login, swap
resolveAnonymousIdentity() for identity.js's resolveIdentity()
(frontend/README.md, "Logging in with Internet Identity").
start() returns the headless DuelClient it drives, and takes
screens (any subset of the default screens replaced by your own
view -> HTML functions), confirm, and promptCode. Reach for
screens when the user wants a screen in the game's own voice (a themed
debrief, a lobby with its own layout) but the interaction model is still
buttons and text. A replaced debrief still draws the final board
(or at least the final round): the move that ended the game is the one
the player most wants to see. If the UI genuinely doesn't fit that
(canvas, 3D, drag and drop, a framework client, its own lobby), read
references/rich-ui.md before writing app.js: it covers binding
createDuelClient() directly, including showing your own move at once.
Step 7 — Project/deploy config
| Template | Destination | Fill in |
|---|---|---|
mops.toml.template |
mops.toml |
__GAME_SLUG__ |
package.json.template |
frontend/package.json |
__GAME_SLUG__ |
.npmrc.template |
frontend/.npmrc |
— |
build.js.template |
frontend/build.js |
__PLUGIN_FILE__ (header comment only) |
icp.yaml.template |
icp.yaml |
— (unless renaming canisters) |
publish_wasm.sh.template |
publish_wasm.sh |
— (next to icp.yaml) |
mops install && mops test
cd frontend && npm install --legacy-peer-deps && npm run build && cd ..
node --check frontend/dist/app.js
icp deploy # local; `icp network start` must be running
icp deploy --network ic # mainnet — spends cycles
The backend always serves its own wasm: the template's icp.yaml runs
sh publish_wasm.sh backend as the backend's sync step, so every
install, reinstall and upgrade uploads the module the canister now runs
to the canister itself, and anyone can download it at GET /wasm and
test a frontend of their own against a local copy (backend README,
"Downloadable wasm"). Keep the step when adapting the file, and
never set snapshot_visibility: public: a snapshot would publish the
whole heap, access codes included. If the backend canister is renamed, pass
the new name to the script. After the first deploy, check the two
public faces of the game:
ID=$(icp canister status backend -i)
curl http://$ID.raw.localhost:8000/semantics
curl -s http://$ID.raw.localhost:8000/wasm | shasum -a 256 # equals module_hash in
icp canister status backend --json
The frontend deploys through the @dfinity/static-site recipe. Its
build step runs npm run build, so deploys always bundle current
source; npm install remains a manual step. The canister sends no
headers of its own: to add a CSP or Cache-Control, put a _headers
file in frontend/src/ and copy it into dist/ from build.js (see
examples/tic-tac-toe/frontend/src/_headers).
Then play both seats in two browser tabs: create a table in one, join from the other, and confirm a round resolves and the debrief/rematch loop works. Check that your move appears the instant you click, that the opponent's move is marked when it lands, and that the debrief still shows the final move. Tests passing and the frontend building are necessary, not sufficient.
Common pitfalls
- No plain Candid method for any game mutation, not even "to test
with a canister call". Use the frontend or a
ws-speaking client. (The HTTP mixin'swasm_upload_*are the engine's own, controllers-only, and touch no game state.) - Never let a client value stand in for something
resolveshould compute (Step 2, point 2). validateis the only legality gate. If the plugin'slegal()disagrees, the plugin has a cosmetic bug; so does anapplyLocalthat disagrees withresolve.- Never hide the last move. A debrief showing only a verdict or a score, or a board with no mark on what the opponent just did, leaves the player guessing what happened.
- No engine bookkeeping in
State. A timestamp or "waiting for opponent" flag in your state means the design drifted from "just the rules". nullverdict means continue, not "no winner ever".- A stale
SEMANTICSis a broken public contract. Third-party frontends are generated from that text alone; re-read it after every rules change.