Skip to content

RFC: Play (v1)

Status: Draft
Version: v1
Scope: Universal runtime interoperability and reference implementation
Non-goals: View rendering, persistence, transport protocols, framework-specific APIs, alternative state engines, non-signal 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 Standardized Signals (TC39) to glue a specific State Engine (XState v5) to a specific Runtime Adapter (TanStack Router) and View Layer (React / 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

  • Signals (TC39 Proposal)
    • Used exclusively for Actor ↔ Adapter communication
    • Enables synchronous, glitch-free propagation

3. Invariants

  1. Actor Authority
    The Actor is the final authority on state and route validity.

  2. Strict Separation
    Business logic never depends on runtime APIs or routing libraries.

  3. Signal-Only Reactivity
    All cross-boundary communication uses standardized Signals.

  4. Passive Infrastructure
    Runtime Adapters do not enforce guards, validation, or business rules.

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

4. Core Mechanisms

4.1 Reactive Substrate (Signals)

  • 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 signal 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 @xmachines/play-signals

Role: Reactive Substrate

Wraps the TC39 Signals (Stage 1) polyfill. By isolating the reactive primitive within this package, the ecosystem is protected from specification churn while providing the ergonomic API surface required by the rest of the architecture.

Exports:

  • Signal.State — Actor output snapshot
  • Signal.Computed — Lazy, pull-based derivation of routes and views
  • Signal.subtle.Watcher — Synchronous observation for Runtime Adapters

5.2 @xmachines/play

Role: Core Protocols

Exports shared contracts that allow the Logic Engine and Infrastructure to communicate without direct dependencies.

Exports:

  • RouterBridge — Interface defining connection / disconnection lifecycle
  • PlayEvent — Generic intent event dispatched from Infrastructure to Actor

5.3 @xmachines/play-actor

Role: Abstract Logic Engine

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

import { Actor, AnyActorLogic } from 'xstate';
import { Signal } from '@xmachines/play-signals';
import { Catalog } from '@json-render/core';
import { PlayEvent } from '@xmachines/play';
// The Base Protocol extending XState's concrete Actor class
export abstract class AbstractActor<TLogic extends AnyActorLogic> extends Actor<TLogic> {
// Reactive Output (Snapshot)
public abstract state: Signal.State<any>;
// Reactive Route (URL Driver)
public abstract currentRoute: Signal.Computed<string | null>;
// Reactive View (UI Schema Driver)
public abstract currentView: Signal.Computed<Record<string, any> | null>;
// The Schema defining the valid UI vocabulary for this actor
public abstract catalog: Catalog;
// Input Channel (inherited from Actor, but typed for PlayEvent)
public abstract send(event: PlayEvent): void;
}

5.4 @xmachines/play-ui

Role: UI Schema Protocol

Defines the guardrailed vocabulary of the application. Contains zero React code.

Exports:

  • defineCatalog — Defines available components and prop schemas
  • z — Zod instance for schema definition

5.5 @xmachines/play-xstate

Role: Concrete Logic Adapter (XState v5)

Wraps XState v5 to satisfy the AbstractActor contract.

Exports:

  • definePlayer

Responsibilities:

  • Bind a Catalog to a machine with type safety
  • Infer meta.view types from the Catalog
  • Derive currentRoute and currentView from active state nodes
  • Initialize actor input from runtime parameters
import { setup, createActor, AnyActorLogic } from 'xstate';
import { Catalog } from '@json-render/core';
import { AbstractActor } from '@xmachines/play-actor';
export function definePlayer<TCatalog extends Catalog>(config: {
catalog: TCatalog;
machine: (types: { meta: { view: any /* Inferred from Catalog */ } }) => any;
}) {
const machineSetup = setup({
types: {
meta: {} as { view: any } // Strictly typed against config.catalog
}
});
const machine = machineSetup.createMachine(config.machine);
return {
// Returns a factory that creates a strictly typed XState Actor extending AbstractActor
create: (options: any): AbstractActor<typeof machine> => {
const actor = createActor(machine, options);
// Attach the Schema to the runtime actor
(actor as any).catalog = config.catalog;
// ... Attach signals to satisfy AbstractActor ...
return actor as unknown as AbstractActor<typeof machine>;
},
// Expose catalog for type inference
catalog: config.catalog
};
}

5.6 @xmachines/play-router

Role: Route Tree Protocol & Utilities

Defines the standardized route tree structure.

Exports:

  • RouteNode — Recursive route definition
  • extractMachineRoutes — Crawl XState machines for static routes

5.7 @xmachines/play-tanstack-router

Role: Concrete Router Adapter

Adapts TanStack Router to the Play Architecture.

Static Builder: createPlayRouter

Guarantees:

  • Enforces AbstractActor usage
  • Infers Catalog → Component mappings
  • Renders a single PlayRenderer for every route

Runtime Adapter Responsibilities:

  • Input: Validate params and initialize Actor via beforeLoad
  • Output: Observe actor.currentRoute and trigger router.navigate
import { AnyActorLogic } from 'xstate';
import { AbstractActor } from '@xmachines/play-actor';
// Type Definition for createPlayRouter
export function createPlayRouter<
TCatalog extends Catalog,
TActor extends AbstractActor<AnyActorLogic>, // Enforce AbstractActor (which extends Actor)
TView extends React.ComponentType<PlayRenderer<TCatalog, TActor>>
>(options: {
player: { catalog: TCatalog; create: (opts: any) => TActor };
// components must match the structure of the player's catalog
components: ComponentMap<TCatalog>;
component: TView;
}) { ... }

5.8 @xmachines/play-react

Role: React View Adapter

Provides the PlayRenderer component. Responsible only for rendering.

import { useSignal } from './hooks';
import { Renderer } from '@json-render/react';
import { AnyActorLogic } from 'xstate';
import { AbstractActor } from '@xmachines/play-actor';
// Defines the props interface with strict Actor typing
export type PlayRenderer<TCatalog extends Catalog, TActor extends AbstractActor<AnyActorLogic>> = {
actor: TActor;
components: ComponentMap<TCatalog>;
};
export function PlayRenderer<TCatalog extends Catalog, TActor extends AbstractActor<AnyActorLogic>>({
actor,
components
}: PlayRenderer<TCatalog, TActor>) {
// 1. Observe the View Signal (Push-Pull)
const spec = useSignal(actor.currentView);
if (!spec) return null;
// 2. Project JSON -> React
return (
<Renderer
spec={spec}
// Validate against the Actor's Schema
catalog={actor.catalog}
// Render using the App's Implementation
components={components}
context={{ actor }}
/>
);
}

6. Usage Model

6.1 Feature Definition (dashboard.player.ts)

Defines Logic + Schema. No React. No Routing.

import { definePlayer } from '@xmachines/play-xstate';
import { defineCatalog, z } from '@xmachines/play-ui';
// 1. Define the Vocabulary (Schema)
export const catalog = defineCatalog({
components: {
Dashboard: { props: z.object({ title: z.string() }) },
Metric: { props: z.object({ value: z.number(), label: z.string() }) }
}
});
// 2. Define the Logic (Machine)
export const player = definePlayer({
catalog,
machine: {
initial: 'overview',
states: {
overview: {
meta: {
route: '/dashboard',
// View determined by Logic
view: {
type: 'Dashboard',
props: { title: 'Q4 Performance' },
children: [
{ type: 'Metric', props: { value: 100, label: 'Sales' } }
]
}
}
}
}
}
});

6.2 Application (App.tsx)

Binds Logic to React and Router.

import { player, catalog } from './features/dashboard/player';
import { PlayRenderer } from '@xmachines/play-react';
import { createPlayRouter } from '@xmachines/play-tanstack-router';
import { defineComponents } from '@json-render/react';
// 1. Define React Implementations
// defineComponents ensures these matches the catalog schema exported above
const components = defineComponents(catalog, {
Dashboard: ({ props, children }) => <div className="p-4">{children}</div>,
Metric: ({ props }) => <span>{props.label}: {props.value}</span>
});
// 2. Instantiate Infrastructure
// The generics <typeof catalog, typeof player.actor> are inferred automatically
const router = createPlayRouter({
player,
components, // Type checked: must match player.catalog
component: PlayRenderer // Type checked: must accept the specific XState actor
});
// 3. Render Provider
function App() {
return <RouterProvider router={router} />;
}

7. Lock Statement

Logic is sovereign.
Infrastructure reflects, never decides.
Reference, not prescription.
Logic owns structure and flow.
Adapters project, never decide.
This is Universal Player v1.