Client API
createSimulatorClient() returns the v0.2 resource-based client.
import { createSimulatorClient } from '@my-swu/simulator-client'
const simulatorClient = createSimulatorClient({
// HTTP origin for `/health` and `/api/*` routes.
baseUrl: 'http://127.0.0.1:4000',
})Options
| Option | Required | Description |
|---|---|---|
baseUrl | yes | HTTP origin of the simulator server. Trailing slash optional. |
fetch | no | Custom fetch implementation. Defaults to global fetch. |
timeoutMs | no | Default timeout for every HTTP resource call. Unset means unbounded. |
validate | no | Runtime schema validator for every payload crossing the boundary. Off by default. See Runtime validation. |
webSocketFactory | no | Custom browser-style WebSocket factory used by match sessions. |
Every HTTP resource method also accepts an optional trailing options argument ({ signal, timeoutMs }) for per-call cancellation. See Cancellation and timeouts.
Runtime validation
The client does not validate payloads at runtime by default. Responses, request bodies and socket frames are handed back as their generated TypeScript types without being checked against the JSON schemas first.
That default is about bundle size. The generated schema map and Ajv are 90% of the SDK's minified weight, and a consumer talking to a server it controls has little use for a second copy of the contract at runtime:
| Bundle | Minified | Gzip |
|---|---|---|
| Default client | 39.1 KB | 9.9 KB |
With validate | 478.2 KB | 102.0 KB |
Measured with esbuild against dist/index.js, bundling a browser entry that constructs a client and one match session. The difference is 322 KB of generated schemas plus 115 KB of Ajv, neither of which is reachable from createSimulatorClient unless you name createSchemaValidator.
Per frame, on a real 3.2 KB server-message snapshot from the engine: decoding costs about 5 µs with validation off — essentially the JSON.parse the SDK has to do anyway — against 13-15 µs with it on, so validation adds 8-10 µs (roughly 2.6-3x) to every frame. Each outbound envelope costs about 0.9 µs to validate.
What the default gives up
- A malformed or truncated server payload is not rejected. It reaches your code typed as if it were valid, and the mismatch surfaces later as an
undefinedfield or aTypeErrorfar from its cause. - A malformed request you build is not caught before it is sent. Instead of a local
SdkValidationErrornaming the offending field, you get whatever the server answers — usually a400throughEngineHttpError. - A server message type this SDK build does not know is no longer dropped during decode. It still cannot be mis-dispatched — the message routers have no case for it — but it does reach the raw
messageevent.
Non-schema failures are unaffected: a frame that is not valid JSON still emits sdk_invalid_server_message, and a non-2xx response still throws EngineHttpError.
Turning it on
Pass createSchemaValidator(). Behaviour then matches the SDK before this option existed: every response, request body and socket frame is checked, and a mismatch throws SdkValidationError with the full error list in details.
import {
createSchemaValidator,
createSimulatorClient,
} from '@my-swu/simulator-client'
const validatingSimulatorClient = createSimulatorClient({
baseUrl: 'http://127.0.0.1:4000',
// Checks every payload against the generated schemas.
validate: createSchemaValidator(),
})
const validatedHealth = await validatingSimulatorClient.system.health()
console.log(validatedHealth.status)Turn it on when you do not control the server, when you are integrating against a new engine build, or in CI and staging where the extra bytes cost nothing and a contract drift should fail loudly. Ship it off in production once the contract is settled.
One validator serves the whole client. Every socket it opens inherits it — session.create(), lobbies.createSession(), lobbies.subscribeList(), and tournamentRooms.createSession() — so all of them answer a malformed frame the same way. See Socket-side decoding failures.
parseSchema() is unaffected by this option: an explicit call always validates, so you can keep checking a specific payload — a persisted save, a hand-built command — without paying for validation on every frame.
import { parseSchema } from '@my-swu/simulator-client'
// Explicit validation still works with the default client.
const persistedCommand = parseSchema('game-command', 'pass')
console.log(persistedCommand)Warming the validator
Schema compilation is lazy, and the first compile pays for the whole shared definition pool: roughly 215 ms for server-message, against 2-42 ms for each schema after it. Left alone, that cost lands on the first frame a socket receives — during connect, on the browser's main thread.
createSimulatorClient() therefore schedules the validator's warmUp() on an idle callback as soon as the client is built (requestIdleCallback with a 2000 ms timeout, falling back to an unref'd setTimeout where that API is absent), which compiles the four socket protocol roots before the handshake needs them. Measured on the connect path, the first validated frame drops from 226 ms to a 7.9 ms median — about 218 ms moved off connect.
This needs nothing from you. It never blocks the constructor, never rejects, and a failed warm-up is swallowed because the same compile is retried against a real payload on first use. With validation off it is a genuine no-op rather than skipped work: the default pass-through validator does not implement warmUp at all, so nothing is scheduled and neither Ajv nor the schema map becomes reachable.
Call it yourself to compile a different set — for a tool that only ever reads HTTP responses, for example:
import { createSchemaValidator } from '@my-swu/simulator-client'
const schemaValidator = createSchemaValidator()
// Compiles now instead of on the first payload that needs these schemas.
schemaValidator.warmUp?.(['get-match-response', 'cards-response'])warmUp is optional on SchemaValidator, and calls are idempotent: a schema already compiled costs nothing.
system
// System health checks are useful for startup gates.
const health = await simulatorClient.system.health()
console.log(health.status)health()calls/health.
cards
// Card ids are loaded by the simulator server.
// 46102 = Leia Organa - Someone Who Loves You; 308 = Echo Base.
const requestedCardMetadata = await simulatorClient.cards.get([46102, 308])
const catalogCards = await simulatorClient.cards.catalog()
const krennicCardId = await simulatorClient.cards.resolveId({
setCode: 'SOR',
cardNumber: 1,
cardTypes: ['leader'],
})
const importedDeck = await simulatorClient.cards.convertDeckJson({
leader: { id: 'SOR_001', count: 1 },
base: { id: 'SOR_025', count: 1 },
deck: [{ id: 'SOR_010', count: 3 }],
})
console.log(requestedCardMetadata[0], catalogCards.length, krennicCardId, importedDeck.cards.length)get(ids)returns canonical card metadata loaded from the server cache.catalog()returns canonical metadata for every production card.resolveId(reference)converts one set code plus printed card number into a card id. Always passcardTypes; use[]when the set number is not type-filtered.resolveIds(references)performs the same lookup in batches and returns not-found or ambiguous match details.convertDeckJson(deckJson)converts my-swu JSON deck exports fromSET_001references into simulatorDeckInput.
matches
// Deck payloads are validated before a match or seat is created.
// `leaders` is the Twin Suns multi-leader list and `sideboard` is accepted only
// by formats whose validation allows one; both stay empty for plain Premier.
const premierHostDeck = {
leader: 46102, // Leia Organa - Someone Who Loves You
leaders: [],
base: 308, // Echo Base
cards: Array.from({ length: 50 }, () => 45), // Alliance X-Wing
sideboard: [],
}
const premierGuestDeck = {
leader: 46107, // Darth Vader - Unstoppable
leaders: [],
base: 309, // Tarkintown
cards: Array.from({ length: 50 }, () => 35), // TIE/ln Fighter
sideboard: [],
}
const hostAccess = await simulatorClient.matches.create({
format: 'premier',
// Twin Suns supports 2-4 players; every other format is exactly 2.
playerCount: 2,
hostDeck: premierHostDeck,
hostName: 'Host',
// True only when the seat arrived from an authenticated app session.
hostAuthenticated: false,
// Identities that should own the seat's server-side history entry.
historyIdentities: [],
// One entry per AI-controlled seat; empty means a human opponent joins.
aiOpponents: [],
})
// `join()` accepts the host access object and extracts the public match id.
const guestAccess = await simulatorClient.matches.join(hostAccess, {
deck: premierGuestDeck,
name: 'Guest',
authenticated: false,
historyIdentities: [],
})
const publicMatchSnapshot = await simulatorClient.matches.get(hostAccess)
// Sessions own socket state for one private seat.
const matchSession = simulatorClient.session.create(hostAccess)
await matchSession.connect()
// Format events handle out-of-game flows such as readiness between games.
await simulatorClient.matches.applyFormatEvent(hostAccess, { type: 'readyNextGame', ready: true })
console.log(guestAccess.seat, publicMatchSnapshot.status, matchSession.connectionState)Match access objects returned by create() and join() can be passed directly to join(), get(), applyFormatEvent(), and session.create().
session
const matchSession = simulatorClient.session.create(hostAccess)
matchSession.on('change', sessionState => {
console.log(sessionState.connectionState, sessionState.seat)
})
await matchSession.connect()
matchSession.ready()Sessions keep private access out of renderable state while exposing connectionState, seat, snapshot, events, and errorMessages.
Action Helpers
import { canDeployAnyLeader, getDeployableLeaderIndices } from '@my-swu/simulator-client'
const leaderCanDeploy = canDeployAnyLeader(matchSnapshot)
const deployableLeaderIndices = getDeployableLeaderIndices(matchSnapshot)
console.log(leaderCanDeploy, deployableLeaderIndices)Leader deploy helpers read snapshot.availableLeaderDeploys, which is computed by the engine. Prefer it over leaderDeployAvailable or leaders[].deployAvailable when enabling deploy controls.
targetGroupsOf(), flattenTargetGroups(), and isSingleTargetPlan() cover the targeting side of the same surface — see Walking target groups.
limitedEvents
// Limited Events create draft or sealed pools before you create a match.
const draftEventAccess = await simulatorClient.limitedEvents.create({
kind: 'draft',
setCode: 'SOR',
playerCount: 4,
// Per-round booster sets for chaos draft; a plain draft leaves this empty.
draftRounds: [],
// Enables the per-pick countdown that auto-picks on timeout.
pickTimerEnabled: false,
seed: '7',
})
const draftSeatAccess = await simulatorClient.limitedEvents.join(draftEventAccess)
// Passing seat access includes viewer-private draft state.
const limitedEventSnapshot = await simulatorClient.limitedEvents.get(draftSeatAccess)
const nextLimitedEventSnapshot = await simulatorClient.limitedEvents.applyCommand(
draftSeatAccess,
{ type: 'readyForNextDraft' },
)
console.log(limitedEventSnapshot.phase.kind, nextLimitedEventSnapshot.phase.kind)Limited Event snapshots expose public pod state plus viewer-private pack, pick, and completed pool details when a valid seat token is supplied.
