Driving Gameplay
The server owns rules and legal actions. Client UIs should render from snapshot, then submit one GameCommand that matches the current prompt.
Source of truth
Use snapshots as authoritative state:
matchSession.on('snapshot', matchSnapshot => {
// Replace local UI state with the server-authoritative projection.
state.value = matchSnapshot
})Use events for secondary UI:
matchSession.on('event', gameEvent => {
// Events are good for logs and animations, not long-lived state.
timeline.value.push(gameEvent)
})Events are useful for logs and animation, but the snapshot is the state to render after every transition.
Ready and setup
After both seats are connected:
// Each seat must signal ready before setup prompts begin.
hostSession.ready()
guestSession.ready()Setup prompts usually advance through:
lobbyReady -> chooseInitiative -> mulligan -> openingResources -> actionMultiplayer setup uses prompt variants keyed by decided seats instead of two fixed booleans:
multiplayerMulligan -> multiplayerOpeningResources -> actionCard text can change setup. Do not hardcode default hand size, mulligan availability, or opening resource count; follow the prompt and the visible snapshot.
Render legal input
Prompt fields identify what the engine waits for:
// Narrow on this value before reading prompt-specific fields.
const prompt = matchSnapshot.promptPair the prompt with the legal option arrays on the same snapshot:
playActionsavailableLeaderDeploysavailableAttacksactionAbilitiesleaderAction- pending ability choice fields
Use availableLeaderDeploys to render leader deploy buttons. The legacy leaderDeployAvailable and leaders[].deployAvailable fields describe whether the leader slot has been spent; they do not prove the deploy action can be paid or has a legal Pilot host.
import { canDeployAnyLeader, getLeaderDeployCommands } from '@my-swu/simulator-client'
if (canDeployAnyLeader(matchSnapshot)) {
const [command] = getLeaderDeployCommands(matchSnapshot)
if (command) {
matchSession.sendCommand(command)
}
}Walking target groups
Every action that asks the player to pick targets carries an ordered list of target-selection groups, but the engine exposes them under two different field names: pendingAbilities[] nests them in targetPlan.groups, while playActions[], actionAbilities[], and leaderAction keep targetGroups inline. That split is deliberate and is not going to be flattened — reshaping it on the wire would touch thousands of engine call sites.
Use targetGroupsOf() instead of writing the ternary yourself. It accepts any member of the TargetingActionView union — plus null and undefined, for the common "nothing selected yet" case — and returns the groups in engine order:
import {
flattenTargetGroups,
isSingleTargetPlan,
targetGroupsOf,
} from '@my-swu/simulator-client'
// One accessor for both shapes; no `targetPlan in action` check at the call site.
const targetingAction = matchSnapshot.pendingAbilities[0] ?? matchSnapshot.playActions[0]
const targetGroups = targetGroupsOf(targetingAction)
for (const targetGroup of targetGroups) {
// `min`/`max` bound the picks; `purpose` is the label hint for this clause.
console.log(targetGroup.min, targetGroup.max, targetGroup.purpose)
}
if (isSingleTargetPlan(targetGroups)) {
// Exactly one mandatory pick, so a single click can resolve the whole plan.
const [onlyCandidate] = flattenTargetGroups(targetGroups)
console.log(onlyCandidate)
}targetGroupsOf(action)reads the groups of any targeting action.flattenTargetGroups(groups)concatenates every group'svalidTargetsin group order. Duplicates are kept, so one entity legal in two groups appears twice — this is what the retired flatvalidTargetsfield carried.isSingleTargetPlan(groups)reports the one plan shape a client can resolve from a single selection: exactly one group withminandmaxboth1. An optional pick (min: 0) or a multi-target group still needs an explicit submit step.
Send commands
Scalar command:
// Scalar commands cover simple actions such as passing.
matchSession.sendCommand('pass')Tagged command:
// Tagged commands carry prompt-specific payloads.
matchSession.sendCommand({
chooseMulligan: {
takeMulligan: false,
},
})A client created with validate: createSchemaValidator() checks outbound command envelopes before sending, and malformed commands throw SdkValidationError synchronously. Validation is off by default — see Runtime validation — and without it the engine rejects the command instead, over the error event.
Credit payments
Five commands can spend Credit tokens, and each carries the exact count as creditTokensToSpend: playCard, useLeaderAction, useActionAbility, chooseAbilityCards, and resolveAbility.
creditTokensToSpend: 0 is the "no payment" value, and it is not optional. Most gameplay spends no Credits at all, so 0 is what a normal command sends — it is a stated amount, not an empty slot. Omitting the field is a different statement, and on four of the five commands it is rejected outright:
// The field is part of a normal command, even when nothing is being paid.
matchSession.sendCommand({
useLeaderAction: {
creditTokensToSpend: 0,
modeIndices: [],
targets: [],
},
})resolveAbility is the exception: its creditTokensToSpend is generated as optional and defaults to 0 when absent. That is backward compatibility, not an ergonomic affordance — the field was added after resolveAbility shipped, so payloads recorded before Plot could spend Credits must keep replaying. New code should send the field on all five commands and treat the default as a property of old data only. Both halves of this — the four required commands rejecting an implicit payment, and resolveAbility accepting a legacy payload as zero — are pinned by round-trip tests on the Rust wire contract, so neither side moves silently.
A non-zero value is only legal where the engine is actually offering a Credit price. The projected action or pending choice enumerates the payable amounts in resourcePaymentOptions[].creditTokenCounts; paying on anything else fails with invalid_selection.
Out-of-game commands
Format workflows use sendFormatEvent() over the socket or matches.applyFormatEvent() over HTTP:
// Socket form updates the connected seat.
matchSession.sendFormatEvent({ type: 'readyNextGame', ready: true })
// HTTP form is useful for tools that already hold match access.
await simulatorClient.matches.applyFormatEvent(matchAccess, {
type: 'sideboard',
deck: sideboardDeck,
})Use format events for sideboarding, reversible best-of-series readiness, Trilogy bans and deck selection, and modeled Draft picks.
Undo and AI Pause
Undo is a session-level rollback, not a game command. Any connected seat may request it:
matchSession.undo()One undo restores the server checkpoint before the latest accepted player action. Prompt follow-up commands, such as ability resolution choices, are grouped with the action that created them. In AI matches, one human undo also consumes any AI responses that happened after that human action.
AI pause is also session-level control:
matchSession.setAiPaused(true)
matchSession.setAiPaused(false)In AI matches, undo pauses the AI before the rollback is applied. That undo-created pause clears automatically after the next successful human ready, game command, or format event. Manual pauses created with setAiPaused(true) remain paused until a client explicitly resumes the AI.
Twin Suns counters
Twin Suns replaces takeInitiative with the shared counter action. Read availableCounters, then submit takeAvailableCounter for one available counter:
matchSession.sendCommand({
takeAvailableCounter: {
counter: 'initiative',
},
})If the counter is plan, follow the next choosePlanCard prompt with a card from that seat's private hand.
Sync and ping
// Sync asks the server to resend the current snapshot.
matchSession.sync()
// Ping echoes the nonce in a later pong event for latency tracking.
matchSession.ping('host-latency-1')sync() requests a fresh snapshot. ping() receives a pong event with the same nonce, which lets apps measure round-trip latency.
