Skip to content

Play - Universal Frontend

Play is XMachines’ approach to building frontend applications that work identically across any JavaScript runtime—browser, Node, Deno, edge functions—with zero code changes.

Most frontend frameworks tightly couple business logic to:

  • Specific routing libraries
  • Browser APIs
  • Framework-specific state management
  • View layer implementations

This makes it difficult to:

  • Test business logic in isolation
  • Reuse logic across platforms (web, mobile, desktop)
  • Build truly universal apps (SSR + CSR + SSG with same code)
  • Let AI agents control navigation based on business rules

Play enforces a strict separation between three concerns:

graph LR
A[Actor<br/>Business Logic] -->|Signals| B[Runtime Adapter<br/>Infrastructure]
B -->|Events| A
A -->|Signals| C[View<br/>Presentation]

1. The Actor (Business Logic Engine)

  • Pure, environment-agnostic logic runtime
  • Owns state, guards, errors, and route validity
  • Emits Virtual Routes as derived intent
  • Has zero knowledge of: Browser APIs, routing libraries, view frameworks

2. 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
  • Never enforces guards, validation, or business rules

3. The View (Passive Presentation)

  • Passive consumer of Actor state
  • No business rules or routing authority
  • Renders based solely on Actor signals

Play uses TC39 Signals exclusively for Actor ↔ Adapter ↔ View communication:

  • Push-pull reactive model
  • Synchronous, glitch-free propagation
  • Standard, framework-agnostic API

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

If the browser says “navigate to /admin” but the Actor rejects it (user not authenticated), the Actor:

  1. Transitions to an error or fallback state (e.g., /login)
  2. Emits the corrected Virtual Route
  3. Forces the browser to realign with Actor reality

Routes are derived from state, not configured separately:

states: {
authenticated: {
meta: {
route: '/dashboard', // Virtual Route
view: { type: 'Dashboard', props: { ... } }
}
},
unauthenticated: {
meta: {
route: '/login',
view: { type: 'LoginForm', props: { ... } }
}
}
}

Invalid external navigation is always overwritten by Actor-derived state.

Play is distributed as multiple focused packages:

PackagePurpose
@xmachines/play-signalsTC39 Signals implementation
@xmachines/play-uiJSON schema + validation (Zod)
@xmachines/play-actorAbstract Actor interface
@xmachines/play-xstateXState v5 adapter + definePlayer
@xmachines/play-routerRoute tree protocol
@xmachines/play-tanstack-routerTanStack Router adapter
@xmachines/play-reactPlayRenderer component

This allows you to:

  • Swap state engines (XState, custom, etc.)
  • Swap routers (TanStack, React Router, custom)
  • Swap view layers (React, Vue, custom)
  • Use only the pieces you need
Terminal window
npm install @xmachines/play-xstate @xmachines/play-react @xmachines/play-tanstack-router

features/dashboard/player.ts - Pure logic, no React, no routing:

import { definePlayer } from '@xmachines/play-xstate';
import { defineCatalog, z } from '@xmachines/play-ui';
// 1. Define the UI 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 business logic (state machine)
export const player = definePlayer({
catalog,
machine: {
initial: 'overview',
states: {
overview: {
meta: {
route: '/dashboard',
view: {
type: 'Dashboard',
props: { title: 'Q4 Performance' },
children: [
{
type: 'Metric',
props: { value: 100, label: 'Sales' }
}
]
}
}
}
}
}
});

App.tsx - Bind 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';
import { RouterProvider } from '@tanstack/react-router';
// 1. Define React component implementations
const components = defineComponents(catalog, {
Dashboard: ({ props, children }) => (
<div className="dashboard">
<h1>{props.title}</h1>
{children}
</div>
),
Metric: ({ props }) => (
<span className="metric">
{props.label}: {props.value}
</span>
)
});
// 2. Create router with type-safe bindings
const router = createPlayRouter({
player,
components, // Type-checked against catalog
component: PlayRenderer
});
// 3. Render
export function App() {
return <RouterProvider router={router} />;
}

That’s it! Your business logic is completely decoupled from React and routing.

Same business logic works for:

  • SSR (server-side rendering)
  • CSR (client-side rendering)
  • SSG (static site generation)
  • Hybrid (islands architecture)
  • Web (browser)
  • Mobile (React Native)
  • Desktop (Electron)
  • Edge functions (Cloudflare Workers, Vercel Edge)

Let AI agents control navigation by sending events to the Actor. The Actor’s business rules determine valid transitions.

Test business logic in isolation—no DOM, no routing library, just pure state machine logic:

import { createActor } from 'xstate';
import { player } from './player';
test('navigation requires auth', () => {
const actor = player.create({ /* ... */ });
actor.start();
// Attempt to navigate without auth
actor.send({ type: 'NAVIGATE', path: '/dashboard' });
// Actor rejects and redirects to login
expect(actor.currentRoute.value).toBe('/login');
});

For complete technical specifications, see the Play v1 RFC.

  • Detailed architecture roles and responsibilities
  • Signal-based reactive substrate
  • Virtual Route protocol
  • Package breakdown and interfaces
  • Type safety guarantees
  • Lock statements (invariants)