Skip to content

RFC: Play

Status: Living
Version: 1.1
Scope: Universal runtime interoperability and reference implementation
Non-goals: Persistence, transport protocols, alternative state engines, non-atom reactivity


1. Purpose

This RFC defines the Universal Player Architecture and its reference implementation. The architecture establishes a design pattern that strictly separates Business Logic (The Actor) from Infrastructure (The Runtime Adapter and View).

The reference implementation provides a modular monorepo that satisfies the architectural constraints of Runtime Agnosticism and Logic-Driven Guarding. It leverages atoms to glue a specific State Engine (XState v5) to multiple Runtime Adapters (TanStack Router, React Router, Vue Router, SolidJS Router) and View Layers (React, Vue, SolidJS, Vanilla DOM — all via JSON-Render), while ensuring that business logic remains the single source of truth for navigation, state, and UI structure.


2. Architecture Model

2.1 Roles

  • The Actor (Logic Engine)

    • Pure, environment-agnostic logic runtime
    • Owns state, guards, errors, and route validity
    • Emits Virtual Routes as derived intent
  • The Runtime Adapter (Infrastructure Layer)

    • Environment-specific adapter (Browser, Native, Server, Test Runner)
    • Reflects Actor output into the environment
    • Forwards environment events to the Actor without interpretation
  • The View

    • Passive consumer of Actor state
    • No business rules or routing authority

2.2 Communication Medium

  • Atoms (@xstate/store)
    • Used exclusively for Actor ↔ Adapter communication
    • Enables synchronous, glitch-free propagation

3. Invariants

This document DEFINES the identifier of each invariant. Every other document and every source comment cites the identifier from this list, and it invents none of its own.

  1. INV-01 — Actor Authority
    The Actor is the final authority on state and route validity.

  2. INV-02 — Strict Separation
    Business logic never depends on runtime APIs or routing libraries.

  3. INV-03 — Atom-Only Reactivity
    All cross-boundary communication uses atoms.

  4. INV-04 — Passive Infrastructure
    Runtime Adapters do not enforce guards, validation, or business rules.

  5. INV-05 — State-Driven Reset
    Invalid external navigation is always overwritten by Actor-derived state.


4. Core Mechanisms

4.1 Reactive Substrate (Atoms)

  • Push–Pull Model
    The Actor pushes state updates; Adapters and Views pull computed values lazily.

  • Glitch-Free Execution
    Updates are synchronous and atomic, preventing intermediate invalid states from leaking into the environment.

4.2 Logic Engine (The Actor)

  • Defines Virtual Routes as metadata on state nodes
  • Validates all incoming navigation intents
  • On invalid intent:
    1. Transitions to an error or fallback state
    2. Emits a corresponding Virtual Route (e.g. /error, /login)
    3. Forces the environment to realign with Actor state

The Actor has zero knowledge of:

  • Browser APIs
  • Routing libraries
  • History mechanisms
  • View frameworks

4.3 Infrastructure Layer (Runtime Adapter)

  • Watcher (Output)
    Observes the Actor’s Intended Route atom and updates the environment accordingly.

  • Reflector (Input)
    Listens to environment events (e.g. back/forward navigation) and forwards them as intents to the Actor.

If an intent is rejected:

  • The Actor emits a new valid state
  • The Adapter overwrites the external environment to match Actor reality

5. Package Model

5.1 Core Layer

5.1.1 @xmachines/play-atom

Role: Reactive Substrate

Re-exports the atom of @xstate/store, whose reactive core adapts the Alien Signals algorithm. By isolating the reactive primitive within this package, the ecosystem is protected from churn in the underlying library while providing the ergonomic API surface required by the rest of the architecture.

Exports:

  • createAtom(value) — The writable atom. It holds the Actor output snapshot, and the derived view
  • createAtom(fn) — The computed atom. It derives a route from the snapshot, and it memoizes the result
  • createWritableAtom(value) — The writable atom, with one signature. Use it when the value type is a type parameter: the overload pair of createAtom reads an unresolved conditional type as a possible function, and it returns a ReadonlyAtom
  • createAsyncAtom(fn) — The computed atom of a promise, with a pending, done and error state
  • watchAtom(atom, onValue) — Convenience function: subscribes to one atom, coalesces the notifications into one microtask, and returns a cleanup function
  • The types Atom, ReadonlyAtom, Readable, BaseAtom, AnyAtom and AtomOptions

One instance: @xstate/store is a PEER dependency, and it must resolve to ONE copy. The library holds the active subscriber in a module variable, so two copies track no dependency across a package boundary. tests/singleton-peers.test.ts holds this rule true for the workspace.

Two properties that a reader has to know:

  • A subscription EVALUATES a computed atom, and it does not merely notify. @xstate/store builds the subscription on an effect that reads the atom, so a computed atom with a subscriber is eager, and a derivation runs one time for each write of a dependency. A computed atom with NO subscriber stays lazy, and it derives when a reader calls get().
  • The propagation is SYNCHRONOUS, inside the set of the atom that changed, and the library exposes no batching. A callback that throws therefore throws inside that set, and an observer of two atoms is evaluated between two writes.

Therefore the Actor writes ONE atom for each transition. state is that atom. currentRoute and currentView are computed atoms over it, so one write reaches both in one propagation and the engine orders the evaluation. Two writes would flush two times, and an observer of state and currentView would read the new state beside the old view in between.

A derivation of the Actor reports through onError, keeps its last good value, and throws never. currentRoute and currentView both follow this rule. A subscriber makes a computed atom eager, so the derivation runs inside the set of state, inside the state-engine subscription: a throw that left it would unwind into the event dispatch and break the transition for every other observer. The containment therefore belongs to the derivation, and not to each subscriber — a consumer that writes its own watchAtom(actor.currentRoute, …) is protected by the same rule that protects a router bridge.

The rule covers the reactive path. A pure helper such as deriveCurrentRoute still throws, because it holds no error channel and no previous value; its callers decide. Construction is the other caller: an unbuildable initialRoute fails the construction, which is where a developer wants to see it.

Infrastructure that runs HOST code from a subscription contains that code by the same reasoning: useAtomEffect of @xmachines/play-react catches a throw of the host callback and throws it again from a microtask.

5.1.2 @xmachines/play

Role: Core Protocols

Exports the minimal shared contracts that allow the Logic Engine and Infrastructure to communicate without direct dependencies. This package is intentionally narrow — it defines only the base event contract and structured error type.

Exports:

  • PlayEvent<TPayload> — Generic intent event dispatched from Infrastructure to Actor. Any object with { readonly type: string } plus optional typed payload fields.
  • PlayError — Structured error base with scope and code fields for consistent error handling across all @xmachines/* packages.

Note: Routing contracts (RouterBridge, PlayRouteEvent) live in @xmachines/play-router, not here. This keeps the core protocol package dependency-free.

5.1.3 @xmachines/play-actor

Role: Abstract Logic Engine

Defines the minimal base class that all logic adapters must implement. By extending the XState Actor class, the PlayActor remains fully compatible with the XState ecosystem (inspection, system registration, message passing) while enforcing the Play Architecture’s push–pull atom contract.

The base class requires only two abstract members: reactive state and typed event dispatch. Routing and view rendering are opt-in via separate interfaces, allowing actors to compose only the capabilities they need.

Design Rationale: The RFC declared an abstract AbstractActor CLASS that extended Actor of xstate, and it declared both capability interfaces in this package. The implementation made the contract an INTERFACE, PlayActor, because the class carried no implementation: both of its members were abstract, and its whole effect was to force the inheritance of XState on every consumer of the contract. @xmachines/play-actor names xstate nowhere now. The implementation also moved each capability interface to the package that holds its shared machinery: Routable is in @xmachines/play-router, beside the bridge that reads it, and Viewable and PlaySpec are in @xmachines/play-view, beside the view store lifecycle. A consumer who needs no routing and no view therefore installs neither package, and @xmachines/play-actor names the capability packages nowhere. The shape of each interface is unchanged.

import { Actor, type AnyActorLogic, type EventObject } from "xstate";
import type {
AnyAtom,
Atom,
AtomOptions,
BaseAtom,
Readable,
ReadonlyAtom,
createAsyncAtom,
createAtom,
createWritableAtom,
watchAtom,
} from "@xmachines/play-atom";
import type { Spec } from "@xmachines/json-render-core";
// Optional capability: Routing
export interface Routable {
readonly currentRoute: ReadonlyAtom<string | null>;
readonly initialRoute: string | null;
}
// Optional capability: View rendering
export interface PlaySpec extends Spec {
// The machine's whole context is projected into the derived view's state
// under the read-only /context subtree; specs read it via
// { $state: "/context/…" } in props, visible conditions, and
// repeat.statePath. /context never accepts writes — machine context
// changes only through events.
//
// Stamped by view derivation from the selected meta entry (never
// authored): providers key their store lifecycle on it — changed key
// reseeds the store, unchanged key refreshes /context in place.
viewKey?: string;
}
export interface Viewable {
readonly currentView: ReadonlyAtom<PlaySpec | null>;
}
// The Base Protocol — minimal contract
export abstract class PlayActor<
TLogic extends AnyActorLogic,
TEvent extends EventObject = EventObject,
> extends Actor<TLogic> {
// Reactive Output (Snapshot)
public abstract state: Atom<unknown>;
// Input Channel (typed for specific event shapes)
public abstract override send(event: TEvent): void;
}

Design Rationale: The original design placed currentRoute, currentView, and catalog directly on PlayActor. The implementation extracted these into Routable and Viewable interfaces because:

  1. Not all actors need routing (e.g. embedded sub-actors)
  2. Not all actors need view rendering (e.g. background logic actors)
  3. Catalog/schema concerns are handled by the renderer layer (@xmachines/json-render-core), not the actor

5.2 Logic Layer

5.2.1 @xmachines/play-xstate

Role: Concrete Logic Adapter (XState v5)

Wraps XState v5 to satisfy the PlayActor contract. Provides the primary API for creating actors from state machine definitions.

Primary Exports:

  • definePlayer — Factory builder: accepts a machine config, returns a factory function that creates PlayerActor instances
  • PlayerActor — Concrete class extending PlayActor. It carries state and send alone, and a capability mixin adds the rest
  • compose, withRouting, withView — The composition of the optional capabilities onto PlayerActor

Utility Exports:

  • Route derivation: deriveRoute, isAbsoluteRoute, buildRouteUrl, formatPlayRouteTransitions

Responsibilities:

  • Derive currentRoute and currentView from active state node metadata
  • Manage atom lifecycle (create on start, dispose on stop)
  • Support snapshot restore for resumable sessions
  • Provide XState DevTools compatibility
import type { AnyStateMachine, InputFrom } from "xstate";
interface PlayerConfig<TMachine extends AnyStateMachine> {
machine: TMachine;
options?: PlayerOptions<TMachine>;
}
// definePlayer returns a factory function, not an object
const definePlayer = <TMachine extends AnyStateMachine>(
config: PlayerConfig<TMachine>,
): PlayerFactory<TMachine> => {
return (input?, restore?) => new PlayerActor(machine, options, input, restore?.snapshot);
};
// PlayerActor carries the core protocol alone. It extends the actor class of XState
// directly, and `PlayActor` is a contract that it implements.
class PlayerActor<TMachine extends AnyStateMachine>
extends Actor<AnyActorLogic>
implements PlayActor<SnapshotFrom<TMachine>, EventFromLogic<TMachine>>
{
public state: Atom<SnapshotFrom<TMachine>>;
}
// A capability arrives through `compose`, and each one brings its own package
const RoutedPlayer = compose(PlayerActor, withRouting, withView);
// RoutedPlayer instances hold `currentRoute`, `initialRoute` and `currentView`

Design Rationale: The RFC made PlayerActor implement both capabilities directly. The implementation split them into mixins, because a direct implementation made @xmachines/play-router and @xmachines/play-view hard dependencies of every actor. Both packages are optional peer dependencies now: definePlayer({ machine }) installs neither, withRouting needs @xmachines/play-router, and withView needs @xmachines/play-view.

Design Rationale: The original design passed a catalog to definePlayer and returned { create, catalog }. The implementation decoupled catalogs entirely — component schemas are defined via @xmachines/json-render-core’s defineCatalog and bound to renderers via defineRegistry, not to actors. This means a single actor can be rendered by different component libraries without change.

5.3 Router Layer

5.3.1 @xmachines/play-router

Role: Route Infrastructure

Provides the complete routing infrastructure: route extraction from state machines, bidirectional route mapping, and the abstract RouterBridgeBase that all framework-specific router adapters extend.

Design Rationale: The RFC put the whole of the routing in one package. The implementation split it at two boundaries, because a consumer paid for both halves and used one:

  • @xmachines/play-router/xstate holds the machine half — extractMachineRoutes, createRouteMap, machineToGraph, and the queries over a route tree. That half alone needs xstate and @statelyai/graph.
  • @xmachines/play-pattern holds the pattern LANGUAGE — one parse of the URLPattern pathname grammar, and the path that buildPath writes back from it. A parse is string work, so the package carries no URLPattern API and no polyfill. BOTH directions of the routing read it: the match of @xmachines/play-router reads the parse, and the routing capability of @xmachines/play-xstate reads the same parse to build a URL, so that package installs no matching machinery.
  • @xmachines/play-url holds the URL LAYER — the URLPattern API with its polyfill, a base path, a location splitter, and the params of a framework router. It reads @xmachines/play-pattern for the language and re-exports it whole. It knows no machine and no bridge.
  • @xmachines/play-router keeps the route map, the match of a location, RouterBridgeBase, the provider lifecycle, and the Routable capability. It names no state machine.

The export lists below name the original layout. Each package README names what it holds now.

Protocol Exports:

  • RouterBridge — Interface: { connect(): void; disconnect(): void }
  • PlayRouteEvent — Routing event: { type: 'play.route'; to: string; params?; query?; match? }
  • RouterBridgeBase — Abstract base class that encapsulates common bridge logic (atom watching, route synchronization, param/query extraction). Subclasses implement only three methods:
    • navigateRouter(path) — Push a path into the framework router
    • watchRouterChanges() — Subscribe to framework router navigation events
    • unwatchRouterChanges() — Unsubscribe from framework router events

Route Extraction Exports:

  • RouteNode, RouteTree, RouteInfo — Recursive route tree types
  • crawlMachine — BFS traversal of state machine graph
  • extractMachineRoutes — Build complete route tree from a machine definition
  • buildRouteTree — Construct hierarchical tree from flat route info
  • createRouteMap / RouteMap — Bidirectional path ↔ state ID resolution

Validation & Query Exports:

  • validateRouteFormat, validateStateExists, detectDuplicateRoutes
  • getNavigableRoutes, getRoutableRoutes, routeExists
  • findRouteById, findRouteByPath

Browser Primitives:

  • createBrowserHistory / BrowserHistory — Browser history abstraction
  • createRouter / VanillaRouter — Framework-agnostic vanilla router
  • connectRouter — Low-level pure browser integration

5.3.2 Framework Router Adapters

Each adapter extends RouterBridgeBase and implements the three abstract methods for its target router. All share the same architectural pattern:

PackageRouterFramework
@xmachines/play-tanstack-react-routerTanStack RouterReact
@xmachines/play-tanstack-solid-routerTanStack RouterSolidJS
@xmachines/play-react-routerReact Router 7/8React
@xmachines/play-vue-routerVue Router 4/5Vue
@xmachines/play-solid-routerSolidJS RouterSolidJS

Runtime Adapter Responsibilities (all adapters):

  • Input (Reflector): Listen to router navigation events, extract params/query, send PlayRouteEvent to Actor
  • Output (Watcher): Observe Actor’s currentRoute atom, call navigateRouter() when it changes
  • Reset: If Actor rejects a navigation (guard failure), the adapter overwrites the URL bar to match Actor state

5.4 View Layer

5.4.1 View Renderers

Each view renderer provides a PlayRenderer component that passively observes the actor’s currentView atom and projects the JSON spec into framework-native components via @json-render.

PackageFrameworkJSON-Render Backend
@xmachines/play-reactReact@xmachines/json-render-react
@xmachines/play-vueVue 3@xmachines/json-render-vue
@xmachines/play-solidSolidJS@xmachines/json-render-solid
@xmachines/play-domVanilla DOM@xmachines/json-render-core

Shared Architecture (all renderers):

  1. Subscribe to actor.currentView atom
  2. Resolve a StateStore (external or auto-created per view transition via @xstate/store)
  3. Wire actions prop to actor.send() via ActionProvider
  4. Render spec through the framework’s Renderer with a registry (defined via defineRegistry)

@xmachines/play-react exports (representative):

// Batteries-included composite — standard entry point
// Props: actor, registryResult, store?, fallback?, onError?, onRenderError?,
// validationFunctions?, navigate?, functions?
export { PlayUIProvider } from "./PlayUIProvider";
export type { PlayUIProviderProps } from "./PlayUIProvider";
// Escape-hatch primitive — owns actor lifecycle, atom bridge, ViewContext
// Props: actor, registryResult, store?, fallback?, onError?, onRenderError?, children (required)
export { ActorProvider } from "./ActorProvider";
export type { ActorProviderProps } from "./ActorProvider";
// Zero-prop leaf — reads usePlayView() from ActorProvider tree; renders <Renderer spec={...} />
// Must be rendered inside <ActorProvider> or <PlayUIProvider>
export const PlayRenderer: React.FC = () => {
/* ... */
};
// Hooks
export { usePlayView } from "./ActorProvider"; // returns ViewContextValue | null
export { useAtomEffect } from "./useAtomEffect";
export { useActor } from "./useActor";
// Error handling
export { PlayErrorBoundary } from "./PlayErrorBoundary";
// Re-exports from @xmachines/json-render-react
export {
defineRegistry,
JSONUIProvider,
StateProvider,
ActionProvider,
VisibilityProvider,
ValidationProvider,
Renderer,
} from "@xmachines/json-render-react";

Design Rationale: PlayRenderer is now a zero-prop leaf component — it reads view context from the enclosing ActorProvider via usePlayView(). All mount-time concerns (actor bridging, atom subscription, store lifecycle, onRenderError injection) are centralised in ActorProvider. PlayUIProvider is the batteries-included composite that wraps ActorProvider + JSONUIProvider with a handler bridge. This mirrors the provider/renderer split in @xmachines/json-render-* and makes the separation of concerns explicit.

5.5 Schema Layer (External)

Component schemas and UI vocabularies are defined via @xmachines/json-render-core, an external package not in the @xmachines scope.

  • defineCatalog — Defines available components and prop schemas (via Zod)
  • defineRegistry — Binds a catalog to framework-specific component implementations (available from each @xmachines/json-render-* framework package)

The original RFC proposed an @xmachines/play-ui package for this purpose. The implementation delegates to @xmachines/json-render-core instead, which provides the same functionality with broader ecosystem support.


6. Usage Model

6.1 Schema Definition (catalog.ts)

Defines the UI vocabulary — available components and their prop schemas. No framework code.

import { defineCatalog, z } from "@xmachines/json-render-core";
export const catalog = defineCatalog({
components: {
Dashboard: { props: z.object({ title: z.string() }) },
Metric: { props: z.object({ value: z.number(), label: z.string() }) },
},
});

6.2 Feature Definition (dashboard.player.ts)

Defines Logic. No framework code. No routing library imports.

import { setup } from "xstate";
import { definePlayer } from "@xmachines/play-xstate";
export const machine = setup({/* types, guards, actions */}).createMachine({
initial: "overview",
states: {
overview: {
meta: {
route: "/dashboard",
view: typedSpec({
root: "root",
elements: {
root: {
type: "Dashboard",
props: { title: "Q4 Performance" },
children: ["metric"],
},
metric: { type: "Metric", props: { value: 100, label: "Sales" } },
},
}),
},
},
},
});
// definePlayer returns a factory function
export const createDashboard = definePlayer({ machine });

6.3 Application (App.tsx)

Binds Logic to a View Renderer and Router Adapter.

import { createDashboard, machine } from "./features/dashboard/player";
import { catalog } from "./catalog";
import { PlayUIProvider, PlayRenderer, defineRegistry } from "@xmachines/play-react";
import { TanStackReactRouterBridge } from "@xmachines/play-tanstack-react-router";
import { extractMachineRoutes, createRouteMap } from "@xmachines/play-router/xstate";
// 1. Create the actor
const actor = createDashboard();
// 2. Define component implementations (type-checked against catalog)
const registryResult = defineRegistry(catalog, {
components: {
Dashboard: ({ props, children }) => <div className="p-4">{children}</div>,
Metric: ({ props }) => (
<span>
{props.label}: {props.value}
</span>
),
},
actions: {
submit: async () => actor.send({ type: "form.submit" }),
},
});
// 3. Build route map from machine definition
const routeTree = extractMachineRoutes(machine);
const routeMap = createRouteMapFromTree(routeTree);
// 4. Connect router adapter (router, actor, routeMap)
const bridge = new TanStackReactRouterBridge(tanstackRouter, actor, routeMap);
bridge.connect();
// 5. Render — PlayUIProvider is the batteries-included composite; PlayRenderer is the zero-prop leaf
function App() {
return (
<PlayUIProvider actor={actor} registryResult={registryResult}>
<PlayRenderer />
</PlayUIProvider>
);
}

7. Lock Statement

Logic is sovereign.
Infrastructure reflects, never decides.
Capabilities compose, never prescribe.
Logic owns structure and flow.
Adapters project, never decide.
This is Universal Player.