Skip to content

End-to-End Tutorial

This tutorial builds a small Vite browser client that runs one two-seat match against a locally running swu-simulator server at http://127.0.0.1:4000.

It is designed to be run as you build it. You will use two browser tabs:

  • Host tab: creates the match, shows the match id, and connects as seat one.
  • Guest tab: pastes the host match id, joins, and connects as seat two.

The final app will:

  • Create and join a Premier match over HTTP.
  • Open one private WebSocket connection in each browser tab.
  • Render only the current tab's private snapshot.
  • Send setup and action commands only for the current tab's seat.
  • Show events, connection state, and errors while you test the flow.

How to read this tutorial

Each section introduces the idea it is about before showing the code. The goal is to make the tutorial explain why the piece exists, where it fits in the match flow, and what kind of deployed client code it would become later. At each new section, first check what the app can already do, then add the next small behavior that moves the match closer to real play.

1. Create the Vite app

The first goal is a clean browser project that can import the SDK. Once this builds, every later section can focus on simulator behavior instead of tooling.

Local browser client

The simulator runs the game, but your player UI can be any web app. This Vite client is the smallest useful shell for calling the SDK, watching state change, and checking that your client and local server can talk to each other.

Start from Vite's vanilla TypeScript template:

sh
pnpm create vite swu-match-client --template vanilla-ts
cd swu-match-client
pnpm add @my-swu/simulator-client

Your project should have this shape:

txt
swu-match-client/
  index.html
  package.json
  src/
    main.ts

Your package.json can stay close to the Vite default:

json
{
  "name": "swu-match-client",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "@my-swu/simulator-client": "^0.2.0"
  },
  "devDependencies": {
    "typescript": "^5.9.3",
    "vite": "^7.0.0"
  }
}

Try it now

Run pnpm build. This first check proves the fresh Vite app and SDK dependency install correctly before you add simulator code.

2. Replace index.html

With the project created, give both browser tabs the same minimal controls. The goal here is not styling; it is a stable set of panels and buttons that later code can update as match state changes.

One tab, one seat

Treat each browser tab as one player's private view. That mirrors a real match: the host tab should see only the host hand and resources, while the guest tab gets its own private snapshot after joining.

Replace Vite's generated page with a small one-seat control surface. The same HTML is used by both tabs. The tab decides whether it is host or guest from app state.

html
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>SWU Match Client</title>
    <script type="module" src="/src/main.ts"></script>
  </head>
  <body>
    <main>
      <h1>SWU Match Client</h1>

      <section>
        <p>
          Seat:
          <strong data-panel="seat-label">Not connected</strong>
        </p>
        <p>
          Connection:
          <strong data-panel="connection-state">idle</strong>
        </p>
      </section>

      <section>
        <button data-action="create-host">Create host match</button>
        <button data-action="connect-seat">Connect seat</button>
        <button data-action="ready">Ready</button>
        <button data-action="take-action">Take action</button>
        <button data-action="stop">Stop</button>
      </section>

      <section>
        <h2>Join by match id</h2>
        <p>
          Host match:
          <code data-panel="match-id">Create a host match first</code>
        </p>
        <label>
          Guest match id
          <input data-input="guest-match-id" type="text" placeholder="Paste match id">
        </label>
        <button data-action="join-guest">Join guest match</button>
      </section>

      <section data-panel="setup-controls" hidden>
        <h2>Setup choices</h2>

        <p data-panel="setup-status"></p>

        <div data-setup-panel="initiative" hidden>
          <h3>Choose initiative</h3>
          <button data-action="choose-initiative" data-seat="one">Seat one</button>
          <button data-action="choose-initiative" data-seat="two">Seat two</button>
        </div>

        <div data-setup-panel="mulligan" hidden>
          <h3>Opening hand</h3>
          <button data-action="choose-mulligan" data-take-mulligan="false">Keep hand</button>
          <button data-action="choose-mulligan" data-take-mulligan="true">Take mulligan</button>
        </div>

        <div data-setup-panel="resources" hidden>
          <h3>Choose starting resources</h3>
          <fieldset data-panel="opening-resource-options"></fieldset>
          <button data-action="choose-opening-resources" disabled>Confirm resources</button>
        </div>
      </section>

      <section>
        <h2>Prompt</h2>
        <pre data-panel="prompt"></pre>
      </section>

      <section>
        <h2>Snapshot</h2>
        <pre data-panel="snapshot"></pre>
      </section>

      <section>
        <h2>Timeline</h2>
        <pre data-panel="timeline"></pre>
      </section>

      <section>
        <h2>Errors</h2>
        <pre data-panel="errors"></pre>
      </section>
    </main>
  </body>
</html>

Replace src/main.ts with an empty module for this first UI check. You will add real app code in the next step.

ts
export {}

Try it now

Run pnpm dev and open Vite's local URL, usually http://localhost:5173. You should see the buttons and empty panels. Leave this dev server running for the rest of the tutorial.

3. Create the SDK session

The page now has places to show state, but nothing owns the player connection yet. This section creates the session object that becomes the single source for seat identity, socket state, snapshots, events, and SDK errors.

SDK session

MatchSession is the client-side home for one player's match state. It remembers which seat this tab controls, keeps the latest snapshot and recent events, and collects errors so the UI can render from one place.

Replace the generated src/main.ts with the smallest useful SDK session. The session owns seat identity, connection state, snapshots, events, and SDK errors.

ts
import { 
createSimulatorClient
} from '@my-swu/simulator-client'
const
simulatorClient
=
createSimulatorClient
({
baseUrl
: 'http://127.0.0.1:4000',
}) const
matchSession
=
simulatorClient
.
session
.
create
()
matchSession
.
on
('change',
render
)
/** * Renders only the panels that have data in the first checkpoint. */ function
render
(): void {
setText
('[data-panel="seat-label"]',
formatSeatLabel
())
setText
('[data-panel="connection-state"]',
matchSession
.
connectionState
)
setText
('[data-panel="errors"]',
matchSession
.
errorMessages
.
join
('\n'))
} /** * Formats the seat currently controlled by this tab. */ function
formatSeatLabel
(): string {
return
matchSession
.
seat
== null
? 'not connected' : `seat ${
matchSession
.
seat
}`
} /** * Writes text content when the matching DOM element exists. */ function
setText
(
selector
: string,
text
: string): void {
const
targetElement
=
document
.
querySelector
(
selector
)
if (
targetElement
!= null) {
targetElement
.
textContent
=
text
} }
render
()

Try it now

Refresh the Vite page. It should show not connected, idle, and no errors. The prompt, snapshot, timeline, and match id panels stay unchanged for now because the app has no simulator data yet.

4. Add session lifecycle helpers

Once a session exists, the next goal is to control its basic lifecycle from small functions. Those helpers keep later button handlers readable and make the same connect, ready, and stop flow reusable.

Session lifecycle

These helpers map common page actions to the session: mark this seat ready, connect its private socket, or close it. Later, the same shape works for route cleanup, reconnect buttons, or a leave-match action.

Append ready, connect, and stop helpers. The session records connection errors and exposes them through matchSession.errorMessages.

ts
/**
 * Sends lobby readiness for this tab's connected seat.
 */
function 
readySeat
(): void {
matchSession
.
ready
()
} /** * Opens the stored private seat access when one exists. */ async function
connectSeat
():
Promise
<void> {
try { await
matchSession
.
connect
()
} catch {
render
()
} } /** * Closes the active socket for this tab. */ function
stopMatch
(): void {
matchSession
.
close
()
render
()
}

Why one session per tab?

Each match session authenticates one private seat. The host tab should not receive guest hand or resource identities, and the guest tab should not receive host private data.

5. Render session snapshots and events

After the lifecycle helpers, the app needs to show what the server sends back. This section expands rendering from connection labels to the full private snapshot, recent events, and public match id.

Snapshot vs event

A snapshot is the whole current game state for this seat. An event is one thing that happened. Build stable UI such as board, hand, and prompt from snapshots; use events for timelines, animations, or short notifications.

Add the SDK type import block to the top of src/main.ts:

ts
import {
  
createSimulatorClient
,
type GameState, type MatchSession, } from '@my-swu/simulator-client'

Append the match id renderer:

ts
/**
 * Updates the public match id shown by host and guest tabs.
 */
function 
renderHostMatchId
(): void {
const
matchIdPanel
=
document
.
querySelector
<HTMLElement>('[data-panel="match-id"]')
if (
matchIdPanel
== null) {
return }
matchIdPanel
.
textContent
=
matchSession
.
matchId
?? 'Create a host match first'
}

Then update render() so it reads all durable UI state from the session:

ts
function 
render
(): void {
setText
('[data-panel="seat-label"]',
formatSeatLabel
())
setText
('[data-panel="connection-state"]',
matchSession
.
connectionState
)
setText
('[data-panel="prompt"]',
JSON
.
stringify
(
matchSession
.
snapshot
?.
prompt
?? null, null, 2))
setText
('[data-panel="snapshot"]',
JSON
.
stringify
(
matchSession
.
snapshot
, null, 2))
setText
('[data-panel="timeline"]',
matchSession
.
events
.
slice
(-10)
.
map
(
gameEvent
=>
JSON
.
stringify
(
gameEvent
))
.
join
('\n'))
setText
('[data-panel="errors"]',
matchSession
.
errorMessages
.
join
('\n'))
renderHostMatchId
()
}

Snapshots are truth

Render durable game UI from snapshot. Events are useful for timelines, animations, and notifications, but a later snapshot is the authoritative state.

6. Create and join by match id

The UI can now display session state, so the next goal is to create real match access. Here the host creates a match, shares only the public id, and the guest uses that id to receive its own private seat access.

Match access

matchId is safe to share because it only identifies the match. The private part is seatToken, which proves this tab may control and view one seat. Keep that token inside the session, the same way you would keep auth data private.

Append a deterministic Premier deck above createHostMatch(). The fixed seed and stable deck make tutorial output easier to compare.

ts
/** Stable Premier demo deck. Each id appears at most three times. */
const 
premierMainDeck
= [
45, 35, 100, 105, 110, 115, 120, 125, 130, 135, 140, 145, 1228, 1230, 1232, 1243, 1244, ].
flatMap
(
cardId
=> [
cardId
,
cardId
,
cardId
])

Then append host creation. The host tab creates the match, stores the private seat access inside the session, and connects only the host seat.

ts
/**
 * Creates a match, connects the host tab, and shows the public match id.
 */
async function 
createHostMatch
():
Promise
<void> {
stopMatch
()
try { await
matchSession
.
createMatch
({
format
: 'premier',
hostDeck
: {
base
: 308, // Echo Base
cards
:
premierMainDeck
,
leader
: 46102, // Leia Organa - Someone Who Loves You
},
seed
: 5,
})
render
()
} catch {
render
()
} }

Append the guest join flow. The pasted matchId is public. The private seatToken comes back from joinMatch() and stays inside this tab's session.

ts
/**
 * Reads the guest match id input.
 */
function 
readGuestMatchIdInput
(): string | null {
const
matchIdInput
=
document
.
querySelector
<HTMLInputElement>('[data-input="guest-match-id"]')
const
pastedMatchId
=
matchIdInput
?.
value
.
trim
() ?? ''
if (
pastedMatchId
.
length
=== 0) {
return null } return
pastedMatchId
} /** * Joins the pasted match as the next open seat and connects the guest tab. */ async function
joinGuestMatch
():
Promise
<void> {
const
pastedMatchId
=
readGuestMatchIdInput
()
if (
pastedMatchId
== null) {
return }
stopMatch
()
try { await
matchSession
.
joinMatch
(
pastedMatchId
, {
deck
: {
base
: 309, // Tarkintown
cards
:
premierMainDeck
,
leader
: 46107, // Darth Vader - Unstoppable
}, })
render
()
} catch {
render
()
} }

Keep seat tokens private

The paste flow only shares matchId. The seatToken still authenticates one private seat, and the session keeps it out of renderable state.

7. Wire the basic buttons

With host and guest flows written, connect them to the controls you added at the start. By the end of this section, two tabs can create, join, connect, ready, and stop a match without typing in the console.

Button wiring

The buttons do not own game logic. They only call the helpers you already wrote, which keeps the page simple and leaves match state inside MatchSession.

Append the button handlers only now, after each basic action exists. You can create the host match, paste the match id into the guest tab, join, ready each tab's seat, and stop either tab.

ts
document
.
querySelector
<HTMLButtonElement>('[data-action="create-host"]')
?.
addEventListener
('click', () => {
void
createHostMatch
()
})
document
.
querySelector
<HTMLButtonElement>('[data-action="connect-seat"]')
?.
addEventListener
('click', () => {
void
connectSeat
()
})
document
.
querySelector
<HTMLButtonElement>('[data-action="join-guest"]')
?.
addEventListener
('click', () => {
void
joinGuestMatch
()
})
document
.
querySelector
<HTMLButtonElement>('[data-action="ready"]')
?.
addEventListener
('click',
readySeat
)
document
.
querySelector
<HTMLButtonElement>('[data-action="stop"]')
?.
addEventListener
('click',
stopMatch
)

Keep render() after the button handlers so the page is initialized after all functions exist:

ts
render
()

Try it now

  1. Run pnpm build.
  2. Start the simulator with cargo run --bin swu-simulator.
  3. Refresh the host tab.
  4. Click Create host match. The host tab should connect and show a guest match id.
  5. Open the Vite URL in a second tab.
  6. Paste the host match id into the guest tab.
  7. Click Join guest match in the guest tab.
  8. Click Ready in both tabs.

After both seats are ready, each tab should show setup prompts in its prompt panel.

8. Add setup prompt types

The match can reach setup after both seats are ready, but the app still only prints raw prompt JSON. This section starts a setup feature so the tutorial can turn engine prompts into focused UI decisions.

Setup prompts

Setup is the short pre-game sequence after both players are ready. The engine asks for initiative, then mulligan choices, then starting resources before it can move into normal action prompts.

Create a src/setup/ folder. Setup is a separate workflow from normal actions: initiative is chosen by one prompted seat, while mulligan and resource choices are answered independently by both seats. Keeping setup helpers in their own folder makes later UI code read like prompt handling instead of generic DOM plumbing.

Start with the shared prompt constants, type aliases, and prompt-kind narrowing. The rest of the setup files import these helpers instead of rechecking raw JSON shape in every click handler.

ts
import type { GameState, MatchSession } from '@my-swu/simulator-client'

/** Premier setup asks each player to resource exactly two cards. */
export const 
PREMIER_OPENING_RESOURCE_COUNT
= 2
/** Prompt union exposed by the current authoritative snapshot. */ type
MatchPrompt
= GameState['prompt']
/** Seat identifier exposed by the current authoritative snapshot. */ export type
Seat
= GameState['players'][number]['seat']
/** One player entry from the current authoritative snapshot. */ type
PlayerState
= GameState['players'][number]
/** Setup prompt families rendered by this client. */ type
SetupPromptKind
= 'initiative' | 'mulligan' | 'resources'
/** * Returns the setup prompt family currently visible to the app. */ export function
getSetupPromptKind
(
matchPrompt
:
MatchPrompt
| null):
SetupPromptKind
| null {
if (!
isPromptObject
(
matchPrompt
)) {
return null } if ('chooseInitiative' in
matchPrompt
) {
return 'initiative' } if ('mulligan' in
matchPrompt
) {
return 'mulligan' } if ('openingResources' in
matchPrompt
) {
return 'resources' } return null } /** * Narrows the prompt away from scalar prompt states. */ export function
isPromptObject
(
matchPrompt
:
MatchPrompt
| null,
):
matchPrompt
is
Exclude
<
MatchPrompt
, string> {
return typeof
matchPrompt
=== 'object' &&
matchPrompt
!= null
}

9. Add setup prompt guards

Now that setup prompt kinds are named, the next goal is deciding which controls this tab is allowed to show. These guards make rendering and submitting follow the same seat-aware rules.

Prompt guards

Every control should be backed by a small guard that checks the current snapshot and this tab's seat. Use the same guard when rendering and when submitting so a late click cannot send an answer for an old prompt.

Append the remaining setup-state helpers to src/setup/state.ts. These functions answer one UI question each: should this tab show a control, which private player state belongs to this tab, and whether this seat has already answered a two-seat prompt.

The decided-seat checks matter because mulligan and opening resources are not active-seat prompts. Both tabs can answer once, then each tab should switch to a waiting state until the other seat answers.

ts
/**
 * Checks whether this tab should show initiative choice buttons.
 */
export function 
shouldShowInitiativeChoices
(
matchSession
: MatchSession): boolean {
const
matchPrompt
=
matchSession
.
snapshot
?.
prompt
?? null
return
matchSession
.
seat
!= null
&&
isPromptObject
(
matchPrompt
)
&& 'chooseInitiative' in
matchPrompt
&&
matchPrompt
.
chooseInitiative
.
seat
===
matchSession
.
seat
} /** * Checks whether this tab should show mulligan choice buttons. */ export function
shouldShowMulliganChoices
(
matchSession
: MatchSession): boolean {
const
matchPrompt
=
matchSession
.
snapshot
?.
prompt
?? null
return
matchSession
.
seat
!= null
&&
isPromptObject
(
matchPrompt
)
&& 'mulligan' in
matchPrompt
&& !
hasDecidedTwoSeatPrompt
(
matchPrompt
.
mulligan
,
matchSession
.
seat
)
} /** * Checks whether this tab should show opening resource choices. */ export function
shouldShowOpeningResourceChoices
(
matchSession
: MatchSession): boolean {
const
matchPrompt
=
matchSession
.
snapshot
?.
prompt
?? null
return
matchSession
.
seat
!= null
&&
isPromptObject
(
matchPrompt
)
&& 'openingResources' in
matchPrompt
&& !
hasDecidedTwoSeatPrompt
(
matchPrompt
.
openingResources
,
matchSession
.
seat
)
} /** * Finds the private player state for this tab's seat. */ export function
findViewerState
(
matchSession
: MatchSession):
PlayerState
| null {
const
viewerSeat
=
matchSession
.
seat
if (
viewerSeat
== null) {
return null } return
matchSession
.
snapshot
?.
players
.
find
(
playerState
=> {
return
playerState
.
seat
===
viewerSeat
}) ?? null } /** * Reads whether one two-player setup prompt already has this seat's answer. */ export function
hasDecidedTwoSeatPrompt
(
promptDecision
: {
seatOneDecided
: boolean,
seatTwoDecided
: boolean },
viewerSeat
:
Seat
,
): boolean { if (
viewerSeat
=== 'one') {
return
promptDecision
.
seatOneDecided
} if (
viewerSeat
=== 'two') {
return
promptDecision
.
seatTwoDecided
} return false }

10. Add setup DOM helpers

The guard functions answer what should happen; the renderer still needs tiny DOM operations to show that state. This section isolates those repetitive writes so the prompt logic stays easy to read.

DOM helper layer

The tutorial uses plain DOM APIs so the SDK calls stay visible. Small helpers keep repetitive querySelector code away from the prompt logic; in a framework client, this layer would usually become components.

Put tiny DOM writes in one file. The setup renderer uses these helpers, and the main app can keep using its existing setText() for the top-level panels. In a larger app this layer would become framework components, but the tutorial keeps plain DOM code so the SDK calls stay visible.

ts
/**
 * Shows or hides one DOM element when it exists.
 */
export function 
setHidden
(
selector
: string,
hidden
: boolean): void {
const
targetElement
=
document
.
querySelector
<HTMLElement>(
selector
)
if (
targetElement
!= null) {
targetElement
.
hidden
=
hidden
} } /** * Writes text content when the matching DOM element exists. */ export function
setText
(
selector
: string,
text
: string): void {
const
targetElement
=
document
.
querySelector
(
selector
)
if (
targetElement
!= null) {
targetElement
.
textContent
=
text
} }

11. Render setup panels

With prompt guards and DOM helpers ready, the next step is the first setup UI pass. The goal is to show the right panel for the current prompt while keeping all command submission out of rendering.

Setup renderer

Rendering setup controls is a read-only pass over the current session. It should decide what the player can see now, then stop. Commands stay separate so drawing a snapshot never answers a prompt by accident.

Add the setup renderer. This file only decides which setup panel is visible and what status text to show. It does not submit commands; keeping render and submit paths separate makes it harder to accidentally auto-answer a prompt while a snapshot is being drawn.

The renderOpeningResourceChoices() import points to the resource renderer you will add in the next step. The app is still easiest to follow when the top-level setup renderer owns the order of all setup panels.

ts
import type { MatchSession } from '@my-swu/simulator-client'

import { 
setHidden
,
setText
} from './dom'
import {
renderOpeningResourceChoices
} from './resources'
import {
getSetupPromptKind
,
hasDecidedTwoSeatPrompt
,
isPromptObject
,
PREMIER_OPENING_RESOURCE_COUNT
,
shouldShowInitiativeChoices
,
shouldShowMulliganChoices
,
shouldShowOpeningResourceChoices
,
} from './state' /** * Renders setup controls for the current private snapshot. */ export function
renderSetupControls
(
matchSession
: MatchSession): void {
const
matchPrompt
=
matchSession
.
snapshot
?.
prompt
?? null
const
setupPromptKind
=
getSetupPromptKind
(
matchPrompt
)
setHidden
('[data-panel="setup-controls"]',
setupPromptKind
== null)
setText
('[data-panel="setup-status"]',
formatSetupStatus
(
matchSession
))
setHidden
('[data-setup-panel="initiative"]', !
shouldShowInitiativeChoices
(
matchSession
))
setHidden
('[data-setup-panel="mulligan"]', !
shouldShowMulliganChoices
(
matchSession
))
setHidden
('[data-setup-panel="resources"]', !
shouldShowOpeningResourceChoices
(
matchSession
))
renderOpeningResourceChoices
(
matchSession
)
} /** * Formats a short status line for the visible setup prompt. */ function
formatSetupStatus
(
matchSession
: MatchSession): string {
const
matchPrompt
=
matchSession
.
snapshot
?.
prompt
?? null
const
viewerSeat
=
matchSession
.
seat
if (
viewerSeat
== null || !
isPromptObject
(
matchPrompt
)) {
return '' } if ('chooseInitiative' in
matchPrompt
) {
return
matchPrompt
.
chooseInitiative
.
seat
===
viewerSeat
? 'Choose which player takes initiative.' : `Waiting for seat ${
matchPrompt
.
chooseInitiative
.
seat
} to choose initiative.`
} if ('mulligan' in
matchPrompt
) {
return
hasDecidedTwoSeatPrompt
(
matchPrompt
.
mulligan
,
viewerSeat
)
? 'Waiting for the other player to choose whether to mulligan.' : 'Choose whether to keep this opening hand or take one mulligan.' } if ('openingResources' in
matchPrompt
) {
return
hasDecidedTwoSeatPrompt
(
matchPrompt
.
openingResources
,
viewerSeat
)
? 'Waiting for the other player to choose starting resources.' : `Choose ${
PREMIER_OPENING_RESOURCE_COUNT
} cards as starting resources.`
} return '' }

12. Render resource cards

The setup panel can now appear, but the resource prompt needs actual hand cards to choose from. This section renders the private hand from this tab's snapshot as checkboxes.

Private hand rendering

The hand comes from this tab's private, seat-aware snapshot. That is why the resource picker can show the current player's cards without ever needing the opponent's hidden card identities.

Create the first version of the resource renderer. It reads the private hand from this tab's snapshot and creates one checkbox per visible hand card.

The app displays card ids instead of full card names so the tutorial can stay focused on match prompts. A deployed client would resolve card metadata from the cards API and render names, aspects, cost, and artwork beside each checkbox.

ts
import type { MatchSession } from '@my-swu/simulator-client'

import {
  
findViewerState
,
shouldShowOpeningResourceChoices
,
} from './state' /** * Renders one checkbox per visible hand card while resources are being chosen. */ export function
renderOpeningResourceChoices
(
matchSession
: MatchSession): void {
const
optionList
=
document
.
querySelector
<HTMLElement>('[data-panel="opening-resource-options"]')
if (
optionList
== null) {
return }
optionList
.
replaceChildren
(
createOpeningResourceLegend
())
if (!
shouldShowOpeningResourceChoices
(
matchSession
)) {
return } const
viewerState
=
findViewerState
(
matchSession
)
for (const [
handIndex
,
cardId
] of (
viewerState
?.
hand
?? []).
entries
()) {
optionList
.
append
(
createOpeningResourceLabel
(
cardId
,
handIndex
, false))
} } /** * Creates the opening resource fieldset legend. */ function
createOpeningResourceLegend
(): HTMLLegendElement {
const
legendElement
=
document
.
createElement
('legend')
legendElement
.
textContent
= 'Hand cards'
return
legendElement
} /** * Creates one selectable hand-card label for the resource prompt. */ function
createOpeningResourceLabel
(
cardId
: number,
handIndex
: number,
checked
: boolean,
): HTMLLabelElement { const
labelElement
=
document
.
createElement
('label')
const
inputElement
=
document
.
createElement
('input')
inputElement
.
type
= 'checkbox'
inputElement
.
name
= 'opening-resource-card'
inputElement
.
checked
=
checked
inputElement
.
dataset
.
cardId
=
String
(
cardId
)
inputElement
.
dataset
.
handIndex
=
String
(
handIndex
)
inputElement
.
dataset
.
input
= 'opening-resource-card'
labelElement
.
append
(
inputElement
, ` Card ${
cardId
}`)
return
labelElement
}

13. Track resource selections

Resource checkboxes now render, but they need to behave like player input rather than throwaway DOM. This section preserves selections across renders and enables the submit button only when the prompt has a valid answer.

Stable resource selections

Decks can contain duplicate card ids, so a checkbox cannot be identified by card id alone. Pairing hand position with card id gives the browser a stable key while the user is choosing resources across repeated renders.

Now update src/setup/resources.ts so resource choices behave like real player input. This step does three things: keeps checkboxes selected across snapshot renders, exports selected card ids for the submit handler, and disables the confirm button until exactly two cards are checked.

The key uses handIndex:cardId instead of only cardId because a deck may contain duplicate cards. The server command needs only card ids, but the browser needs stable checkbox identity while rendering the current hand.

First update the state import in src/setup/resources.ts:

ts
import {
  
findViewerState
,
PREMIER_OPENING_RESOURCE_COUNT
,
shouldShowOpeningResourceChoices
,
} from './state'

Then update renderOpeningResourceChoices():

ts
export function 
renderOpeningResourceChoices
(
matchSession
: MatchSession): void {
const
optionList
=
document
.
querySelector
<HTMLElement>('[data-panel="opening-resource-options"]')
if (
optionList
== null) {
return } const
selectedKeys
=
readSelectedOpeningResourceKeys
()
optionList
.
replaceChildren
(
createOpeningResourceLegend
())
if (!
shouldShowOpeningResourceChoices
(
matchSession
)) {
updateOpeningResourceSubmitState
()
return } const
viewerState
=
findViewerState
(
matchSession
)
for (const [
handIndex
,
cardId
] of (
viewerState
?.
hand
?? []).
entries
()) {
const
optionKey
=
formatOpeningResourceKey
(
cardId
,
handIndex
)
optionList
.
append
(
createOpeningResourceLabel
(
cardId
,
handIndex
,
selectedKeys
.
has
(
optionKey
)))
}
updateOpeningResourceSubmitState
()
}

Finally append the selection helpers to src/setup/resources.ts:

ts
/**
 * Reads selected opening resource card ids from the rendered checkboxes.
 */
export function 
readSelectedOpeningResourceCardIds
(): number[] {
return
getOpeningResourceCheckboxes
()
.
filter
(
inputElement
=>
inputElement
.
checked
)
.
flatMap
(
inputElement
=> {
const
cardId
=
Number
.
parseInt
(
inputElement
.
dataset
.
cardId
?? '', 10)
return
Number
.
isFinite
(
cardId
) ? [
cardId
] : []
}) } /** * Enables the resource submit button only after exactly two cards are selected. */ export function
updateOpeningResourceSubmitState
(): void {
const
submitButton
=
document
.
querySelector
<HTMLButtonElement>(
'[data-action="choose-opening-resources"]', ) const
selectedCardIds
=
readSelectedOpeningResourceCardIds
()
const
selectedLimitReached
=
selectedCardIds
.
length
>=
PREMIER_OPENING_RESOURCE_COUNT
for (const
inputElement
of
getOpeningResourceCheckboxes
()) {
inputElement
.
disabled
=
selectedLimitReached
&& !
inputElement
.
checked
} if (
submitButton
!= null) {
submitButton
.
disabled
=
selectedCardIds
.
length
!==
PREMIER_OPENING_RESOURCE_COUNT
} } /** * Reads selected opening resource checkbox keys for preserving render state. */ function
readSelectedOpeningResourceKeys
():
Set
<string> {
return new
Set
(
getOpeningResourceCheckboxes
()
.
filter
(
inputElement
=>
inputElement
.
checked
)
.
map
(
inputElement
=> {
return
formatOpeningResourceKey
(
Number
.
parseInt
(
inputElement
.
dataset
.
cardId
?? '', 10),
Number
.
parseInt
(
inputElement
.
dataset
.
handIndex
?? '', 10),
) }), ) } /** * Formats a stable resource checkbox key for duplicate card ids. */ function
formatOpeningResourceKey
(
cardId
: number,
handIndex
: number): string {
return `${
handIndex
}:${
cardId
}`
} /** * Returns all opening resource checkbox inputs currently in the document. */ function
getOpeningResourceCheckboxes
(): HTMLInputElement[] {
return
Array
.
from
(
document
.
querySelectorAll
<HTMLInputElement>('[data-input="opening-resource-card"]'),
) }

14. Connect setup rendering

The setup feature is useful only if the main render loop calls it after every snapshot. This section adds the feature boundary, then plugs setup rendering into main.ts.

Feature barrel

src/setup/index.ts is the public doorway into the setup feature. main.ts imports the setup behavior it needs without caring which internal file holds the renderer, command wiring, or prompt helpers.

Export the setup renderer through a folder barrel and call it from render(). This keeps main.ts from knowing which setup files exist. It only knows the setup feature has one render entrypoint.

ts
import { 
renderSetupControls
} from './setup'

Add the setup barrel:

ts
export { 
renderSetupControls
} from './render'

Then update render() in src/main.ts so setup controls follow each new snapshot:

ts
function 
render
(): void {
setText
('[data-panel="seat-label"]',
formatSeatLabel
())
setText
('[data-panel="connection-state"]',
matchSession
.
connectionState
)
setText
('[data-panel="prompt"]',
JSON
.
stringify
(
matchSession
.
snapshot
?.
prompt
?? null, null, 2))
setText
('[data-panel="snapshot"]',
JSON
.
stringify
(
matchSession
.
snapshot
, null, 2))
setText
('[data-panel="timeline"]',
matchSession
.
events
.
slice
(-10)
.
map
(
gameEvent
=>
JSON
.
stringify
(
gameEvent
))
.
join
('\n'))
setText
('[data-panel="errors"]',
matchSession
.
errorMessages
.
join
('\n'))
renderHostMatchId
()
renderSetupControls
(
matchSession
)
}

15. Submit initiative and mulligan

Rendering setup prompts is not enough; the app must answer them with commands. This section starts the command path with the two setup decisions that do not need card selection.

Setup commands

Once a player clicks a setup control, the handler turns that choice into a GameCommand. The command path checks prompt guards again because the snapshot may have changed after the button was rendered.

Add command handlers in their own file. Start with initiative and mulligan because both commands submit scalar choices from visible buttons.

Each handler calls the same prompt guards as the renderer. That extra check is intentional: if a delayed click fires after a fresh snapshot arrives, the app returns early instead of sending a stale command to the server.

ts
import type { MatchSession } from '@my-swu/simulator-client'

import {
  type 
Seat
,
shouldShowInitiativeChoices
,
shouldShowMulliganChoices
,
} from './state' /** * Wires setup prompt buttons to matching simulator commands. */ export function
wireSetupPromptButtons
(
matchSession
: MatchSession): void {
wireInitiativeChoiceButtons
(
matchSession
)
wireMulliganChoiceButtons
(
matchSession
)
} /** * Wires initiative player choice buttons. */ function
wireInitiativeChoiceButtons
(
matchSession
: MatchSession): void {
document
.
querySelectorAll
<HTMLButtonElement>('[data-action="choose-initiative"]')
.
forEach
(
buttonElement
=> {
buttonElement
.
addEventListener
('click', () => {
chooseInitiativePlayer
(
matchSession
,
readSeatChoice
(
buttonElement
))
}) }) } /** * Sends the selected initiative player. */ function
chooseInitiativePlayer
(
matchSession
: MatchSession,
initiativeSeat
:
Seat
| null): void {
if (
initiativeSeat
== null || !
shouldShowInitiativeChoices
(
matchSession
)) {
return }
matchSession
.
sendCommand
({
chooseInitiativePlayer
: {
seat
:
initiativeSeat
,
}, }) } /** * Reads a valid two-seat choice from one button. */ function
readSeatChoice
(
buttonElement
: HTMLButtonElement):
Seat
| null {
const
seatChoice
=
buttonElement
.
dataset
.
seat
return
seatChoice
=== 'one' ||
seatChoice
=== 'two'
?
seatChoice
: null } /** * Wires opening hand mulligan buttons. */ function
wireMulliganChoiceButtons
(
matchSession
: MatchSession): void {
document
.
querySelectorAll
<HTMLButtonElement>('[data-action="choose-mulligan"]')
.
forEach
(
buttonElement
=> {
buttonElement
.
addEventListener
('click', () => {
chooseMulligan
(
matchSession
,
buttonElement
.
dataset
.
takeMulligan
=== 'true')
}) }) } /** * Sends whether this player takes one mulligan. */ function
chooseMulligan
(
matchSession
: MatchSession,
takeMulligan
: boolean): void {
if (!
shouldShowMulliganChoices
(
matchSession
)) {
return }
matchSession
.
sendCommand
({
chooseMulligan
: {
takeMulligan
,
}, }) }

16. Submit opening resources

Initiative and mulligan now submit from buttons, leaving the card-selection setup prompt. This section sends exactly the two resource cards the player chose instead of deriving a hidden default.

Resource command

Opening resources are not guessed by the client. The player selects the exact cards, and the command sends those ids so the server can move the same cards from hand into resources.

Extend src/setup/commands.ts with the resource command path. The resource submit handler reads the exact checked cards from the DOM and sends those ids. It does not fall back to "first two cards"; the player must choose two cards and click Confirm resources.

First add the resource import and update the existing state import:

ts
import {
  
readSelectedOpeningResourceCardIds
,
updateOpeningResourceSubmitState
,
} from './resources' import {
PREMIER_OPENING_RESOURCE_COUNT
,
type
Seat
,
shouldShowInitiativeChoices
,
shouldShowMulliganChoices
,
shouldShowOpeningResourceChoices
,
} from './state'

Then update wireSetupPromptButtons():

ts
export function 
wireSetupPromptButtons
(
matchSession
: MatchSession): void {
wireInitiativeChoiceButtons
(
matchSession
)
wireMulliganChoiceButtons
(
matchSession
)
wireOpeningResourceChoiceControls
(
matchSession
)
}

Finally append the resource handlers:

ts

/**
 * Wires opening resource checkbox and submit controls.
 */
function 
wireOpeningResourceChoiceControls
(
matchSession
: MatchSession): void {
document
.
querySelector
<HTMLElement>('[data-panel="opening-resource-options"]')
?.
addEventListener
('change',
updateOpeningResourceSubmitState
)
document
.
querySelector
<HTMLButtonElement>('[data-action="choose-opening-resources"]')
?.
addEventListener
('click', () => {
chooseOpeningResources
(
matchSession
)
}) } /** * Sends selected opening resource card ids. */ function
chooseOpeningResources
(
matchSession
: MatchSession): void {
if (!
shouldShowOpeningResourceChoices
(
matchSession
)) {
return } const
cardIds
=
readSelectedOpeningResourceCardIds
()
if (
cardIds
.
length
!==
PREMIER_OPENING_RESOURCE_COUNT
) {
return }
matchSession
.
sendCommand
({
chooseOpeningResources
: {
cardIds
,
}, }) }

17. Wire setup command handlers

All setup commands exist, so the next goal is attaching them once when the page starts. The handlers stay live across prompt changes because each click reads the current snapshot before sending.

Long-lived handlers

These listeners are attached once when the page starts. They still stay current because they read matchSession.snapshot at click time instead of capturing the prompt that was visible when the listener was registered.

Export the setup command wiring, then call it once when the page initializes. The listeners stay active for the lifetime of the tab. They read current matchSession.snapshot on every click, so they follow prompt changes without being reattached after each render.

ts
export { 
wireSetupPromptButtons
} from './commands'
export {
renderSetupControls
} from './render'
export {
isPromptObject
} from './state'
ts
import {
  
isPromptObject
,
renderSetupControls
,
wireSetupPromptButtons
,
} from './setup'

Call wireSetupPromptButtons() after the basic button handlers and before the initial render() call:

ts
wireSetupPromptButtons
(
matchSession
)

Try it now

Run pnpm build. Then refresh both tabs, create and join a match, and click Ready in both tabs. Choose the initiative player, choose whether to mulligan in each tab, then select two hand cards as resources in each tab. Both prompt panels should advance to an action prompt.

18. Add action prompt helpers

Setup is now player-driven. The next section keeps action-phase driving small and deterministic so you can test the socket flow after setup.

The goal here is to recognize when this tab owns the action prompt. Once that ownership check exists, attack and play helpers can stay focused on one command type at a time.

Action prompt ownership

During the action phase, the prompt says which seat may act. Check that ownership before sending attacks, plays, initiative, or pass commands so the inactive tab can safely do nothing.

Create action helpers in a separate src/action/ folder. The state file owns action prompt narrowing and SDK-derived types, so attack and play helpers do not need to inspect raw prompt shape.

ts
import type { GameState, MatchSession } from '@my-swu/simulator-client'

import { 
isPromptObject
} from '../setup'
/** One action prompt payload from the current snapshot. */ type
ActionPrompt
=
Extract
<GameState['prompt'], {
action
: unknown }>['action']
/** One projected play action from the current snapshot. */ export type
PlayAction
= GameState['playActions'][number]
/** One command payload accepted by MatchSession.sendCommand. */ export type
GameCommandPayload
=
Parameters
<MatchSession['sendCommand']>[0]
/** * Returns the current action prompt when this tab owns it. */ export function
getOwnedActionPrompt
(
matchSession
: MatchSession):
ActionPrompt
| null {
if (
matchSession
.
snapshot
== null
||
matchSession
.
seat
== null
) { return null } const
matchPrompt
=
matchSession
.
snapshot
.
prompt
if ( !
isPromptObject
(
matchPrompt
)
|| !('action' in
matchPrompt
)
||
matchPrompt
.
action
.
seat
!==
matchSession
.
seat
) { return null } return
matchPrompt
.
action
}

19. Add attack action helper

Action prompt ownership is now checked, so the tutorial can try one concrete action. This section starts with attacks because the snapshot already lists the legal attacker and target pairs.

Server-projected attacks

availableAttacks is already filtered by the engine for the current snapshot. The client can present those attacker and target choices without reimplementing combat legality in TypeScript.

Add the attack helper. It only submits when the snapshot already exposes a legal attacker and a legal target. This keeps target validation in the server-owned snapshot projection instead of inventing target rules in the client.

ts
import type { MatchSession } from '@my-swu/simulator-client'

/**
 * Sends the first attack option with its first legal target.
 */
export function 
sendFirstAvailableAttack
(
matchSession
: MatchSession): boolean {
if (
matchSession
.
snapshot
== null) {
return false } const
attackChoice
=
matchSession
.
snapshot
.
availableAttacks
[0]
const
attackTarget
=
attackChoice
?.
validTargets
[0]
if (
attackChoice
== null ||
attackTarget
== null) {
return false }
matchSession
.
sendCommand
({
attack
: {
attackerId
:
attackChoice
.
attackerId
,
target
:
attackTarget
,
}, }) return true }

20. Add simple play helper

If no attack is available, the test driver still needs another legal way to move the game forward. This section adds a conservative play-card helper that only uses snapshot-projected actions with no extra player choices.

Simple play action

Some cards can be played without extra choices. The tutorial uses those simple plays to keep the match moving, while a deployed client would render the full set of targets, modes, exploit choices, captures, and attachments from the same playActions data.

Add the simple play helper. It intentionally skips cards that need extra UI choices such as targets, modes, exploit units, captures, or attachments. A real game client would render those legal choices from the same playActions entries instead of skipping them.

ts
import type { MatchSession } from '@my-swu/simulator-client'

import type { 
GameCommandPayload
,
PlayAction
} from './state'
/** * Sends the first play action that needs no extra target, mode, or attachment. */ export function
sendFirstSimplePlayAction
(
matchSession
: MatchSession): boolean {
if (
matchSession
.
snapshot
== null) {
return false } const
playChoice
=
matchSession
.
snapshot
.
playActions
.
find
(
isSimplePlayAction
)
if (
playChoice
== null) {
return false }
matchSession
.
sendCommand
(
buildPlayCardCommand
(
playChoice
))
return true } /** * Builds the smallest valid PlayCard command from one projected play action. */ function
buildPlayCardCommand
(
playChoice
:
PlayAction
):
GameCommandPayload
{
const
playCommand
= {
playCard
: {
cardId
:
playChoice
.
cardId
,
...(
playChoice
.
source
.
resourceIndex
== null
? {} : {
resourceIndex
:
playChoice
.
source
.
resourceIndex
}),
}, } satisfies
GameCommandPayload
return
playCommand
} /** * Checks whether a play action can be submitted without extra UI choices. */ function
isSimplePlayAction
(
playChoice
:
PlayAction
): boolean {
return
playChoice
.
attachmentMode
=== 'none'
&&
playChoice
.
captureOptions
== null
&&
playChoice
.
exploit
.
max
=== 0
&&
playChoice
.
modeChoice
== null
&&
playChoice
.
targetGroups
.
every
(
targetGroup
=>
targetGroup
.
min
=== 0)
}

21. Wire the action button

The action helpers are ready; now the button needs one clear decision order. This final app feature tries the safest automatic move available so both tabs can exercise the full prompt loop.

Action driver

The Take action button is a deterministic test driver, not a model for real player UX. It tries one legal attack, then one simple play, then initiative or pass so you can verify the full prompt loop before building richer controls.

Finally add the action driver and wire the button. This keeps the "try one action" behavior in one small module, while the details of attacking and playing cards stay in their own files.

ts
import type { MatchSession } from '@my-swu/simulator-client'

import { 
sendFirstAvailableAttack
} from './attacks'
import {
sendFirstSimplePlayAction
} from './play'
import {
getOwnedActionPrompt
} from './state'
/** * Sends one simple action for this tab's seat. */ export function
takeNextAction
(
matchSession
: MatchSession): void {
const
actionPrompt
=
getOwnedActionPrompt
(
matchSession
)
if (
actionPrompt
== null) {
return } if (
sendFirstAvailableAttack
(
matchSession
)) {
return } if (
sendFirstSimplePlayAction
(
matchSession
)) {
return } if (
actionPrompt
.
canTakeInitiative
) {
matchSession
.
sendCommand
('takeInitiative')
return }
matchSession
.
sendCommand
('pass')
}

Import the action driver in src/main.ts:

ts
import { 
takeNextAction
} from './action'

Wire the action button:

ts
document
.
querySelector
<HTMLButtonElement>('[data-action="take-action"]')
?.
addEventListener
('click', () => {
takeNextAction
(
matchSession
)
})

The server owns legality. A deployed UI should let players choose targets, modes, attachments, exploit units, and optional ability choices from the legal arrays in the same snapshot.

Try it now

Click Take action only in the tab whose prompt shows the active seat. Watch the timeline and prompt panels update in both tabs after each command.

22. Final run checklist

At this point the client has all pieces: match access, private sockets, setup choices, and one deterministic action button. Use this checklist to run the whole path from empty tabs to the first action prompt.

Use this order for a clean end-to-end run:

  1. Start the simulator from the swu-simulator repository:

    sh
    cargo run --bin swu-simulator
  2. Start the Vite app from swu-match-client:

    sh
    pnpm dev
  3. Open the Vite URL in the first tab.

  4. Click Create host match in the first tab.

  5. Open the Vite URL in a second tab.

  6. Paste the host match id into the guest tab.

  7. Click Join guest match in the guest tab.

  8. Click Ready in both tabs.

  9. Choose the initiative player in the prompted tab.

  10. Choose Keep hand or Take mulligan in each tab.

  11. Select two starting resource cards and click Confirm resources in each tab.

  12. Click Take action in the tab that has the current action prompt.

Troubleshooting

Server down: If Create host match, Join guest match, or Connect seat reports a network error, make sure cargo run --bin swu-simulator is running and listening on http://127.0.0.1:4000.

Missing guest access: If the guest tab says match session has no access, paste the host match id and click Join guest match before connecting.

Stale tab state: If you stop or refresh only one tab, the other tab may still be connected to the old match. For a clean run, refresh both tabs and create a new host match.

Wrong active seat: If a button appears to do nothing, check the prompt. The setup and action helpers return early when this tab is not the seat currently being prompted.

Real client rules

This client is intentionally small, but the same boundaries matter in a deployed client. Keep these rules when you replace the plain DOM controls with your real UI.

matches.create() and matches.join() return private access objects. The matchId can be used for routing, but seatToken is a seat credential. Store and transmit it like auth data.

Each session.create() call creates state for exactly one seat. Keep one session per player tab so private hand and resource identities stay scoped to the correct player.

The snapshot is the source of truth. Events are useful for timelines, animations, and notifications, but durable game UI should render from the latest snapshot.

Call stopMatch() when the page unmounts or the user leaves the match. If the user stays on the match page and the socket drops, call matchSession.reconnect() on the existing session instead; listeners remain registered. See Reconnect Handling for backoff and recovery patterns.

Next steps

After the end-to-end flow works, use the focused guides below to replace the tutorial shortcuts with full client behavior.

Released under the MIT License.