sylph-jsx
v0.7.3
Published
A lightweight, SolidJS-powered runtime for building declarative PixiJS experiences
Maintainers
Readme
Status
The framework is fully-functional, but APIs are subject to change.
Quick Start
Sylph offers a typescript template:
npx degit dpchamps/sylph.jsx/packages/sylph-template my-sylph-app
cd my-sylph-app
npm install
npm run devOverview
Sylph uses the SolidJS Universal Renderer to construct a PixiJS container hierarchy.
Additionally, it provides top-level mechanisms for writing declarative components with fine-grained reactivity. Effects are synchronized to the PixiJS ticker to ensure deterministic sequencing and updating within a given frame.
What this means is that you can write PixiJS applications that leverage SolidJS reactive primitives. An example would be:
import {
createAsset,
Application,
createSignal,
render,
onEveryFrame,
} from "sylph-jsx";
const App = () => {
const texture = createAsset("fire.png");
const [angle, setAngle] = createSignal(0);
onEveryFrame((time) => {
setAngle((last) => last - 0.05 * time.deltaTime);
});
return (
<Application width={800} height={600} backgroundColor={0x101820}>
<sprite
texture={texture()}
pivot={{ x: 0.5, y: 0.5 }}
x={400}
y={300}
rotation={angle()}
/>
</Application>
);
};The above example creates an Application component, with a single sprite as a child.
It uses the onEveryFrame lifecycle hook to execute a side effect on every frame of the PixiJS ticker. The side effect
in this case is updating the angle signal. Note that we have ticker primitives available: time.deltaTime
is used to ensure smoothness across frames.
We can then consume this signal in the sprite component below.
The end result is that we have updates applied to containers that trigger re-renders only when the reactive primitives they're dependent on are triggered. Read more about fine-grained reactivity here: SolidJS Docs on fine-grained reactivity.
Performance
Sylph's fine-grained reactivity enables performance characteristics that are difficult to achieve with traditional component-based rendering:
- Frame-synchronized effects: All reactive updates are scheduled to perform in a single tick.
- The scheduler will flush as many effect-cascades as possible within a budgeted frame window
- Granular updates: Only the specific properties that changed are updated, not entire component trees.
- If a sprite's
xposition changes, only that property is set—nothing else re-renders.
- If a sprite's
Examples:
- BasicReactivityLoadTest demonstrates 3,000+ individually tracked sprites, each with reactive position, scale, and rotation properties updating in real-time using coroutine-based easing animations—all while maintaining 60fps with minimal performance impact.
This makes Sylph particularly well-suited for interactive visualizations, 2D games, and real-time data displays where declarative code and smooth performance are both important.
Development Features
- TypeScript support: All JSX elements have complete type definitions—your IDE will autocomplete PixiJS properties and catch errors before runtime.
- PixiJS devtools: The devtools overlay boots automatically in development, giving you scene graph inspection and performance monitoring.
- Full SolidJS compatibility: All SolidJS primitives and components work with PixiJS containers. Use
<Show>,<For>,<Index>,createMemo,createResource, and any other SolidJS feature—they all just work.
Motivations
Sylph is intended to be a general-purpose framework for writing canvas/webgpu PixiJS applications. The personal goals that inspired this project were to have a general suite of tools that enabled performant push-based reactivity for game development.
For example:
const PLAYER_SPEED = 5;
const DESTINATION = { x: 500, y: 500 };
const ExampleSprite = (props: PixiNodeProps<{ x: number; y: number }>) => {
const texture = createAsset<Texture>("fire.png");
return (
<sprite
texture={texture()}
scale={1}
x={props.x}
y={props.y}
tint={"white"}
pivot={{ x: 0.5, y: 0.5 }}
/>
);
};
export const ControlsAndMovement = () => {
const wasdController = createWASDController();
const [playerPosition, setPlayerPosition] = createSignal({ x: 0, y: 0 });
const winCondition = () =>
euclideanDistance(playerPosition(), DESTINATION) < 20;
createSynchronizedEffect(wasdController, ({ x, y }, time) => {
setPlayerPosition((last) => ({
x: last.x + x * PLAYER_SPEED * time.deltaTime,
y: last.y + y * PLAYER_SPEED * time.deltaTime,
}));
});
return (
<Show when={!winCondition()} fallback={<text>You Won!</text>}>
<ExampleSprite x={playerPosition().x} y={playerPosition().y} />
<ExampleSprite x={DESTINATION.x} y={DESTINATION.y} />
</Show>
);
};The above example demonstrates generally how we can compose effects and signals together to write a declarative scene where parts only update when the dependencies change:
- The first
ExampleSpriteupdates only when player position changes - The second
ExampleSpritenever updates - The
Showblock only updates when the winCondition changes
Further, the example shows how effects, signals and components are composable:
winConditionis a simple function, but it preserves reactivity from its inner computationExampleSpriteis a reusable component for a sprite with a given texturewasdController(not shown in example) is similar towinCondition: derived signals bound to input
This kind of development may not be desirable or appropriate for parts of the application. The component PixiExternalContainer
is provided to allow you to eject from the reactive runtime at any point and perform your own logic directly in
a PixiJS container. See #pixiexternalcontainer below for more info.
Development
npm install
npm run test # run test suite with watch, show coverage
npm run dev -w slyph-examples # run the sandbox appQuick start
JSX primitives
<application>
Handles PixiJS initialization, waits to mount until .initialize() runs, and accepts standard ApplicationOptions plus loadingState, appInitialize, and createTicker hooks.
<application width={800} height={600} backgroundColor={0x101820}>
{children}
</application><container>
Standard scene graph grouping element. Accepts other intrinsics (including <render-layer>) and supports PixiJS container options such as sortableChildren, eventMode, and position properties.
<container x={100} y={120} sortableChildren>
<sprite texture={texture()} />
<text>x: {position().x}</text>
</container><sprite>
Leaf element for textured display objects. Provide a PixiJS Texture, positional props, interactivity settings, and transforms.
<sprite
texture={createAsset("fire.png")()}
x={400}
y={300}
pivot={{ x: 0.5, y: 0.5 }}
eventMode="static"
onclick={() => setAngle((angle) => angle + 0.1)}
/><text>
Displays dynamic copy. String children automatically concatenate into the PixiJS Text value, and updates react when signals change.
<text style={{ fontSize: 24 }} fill={0xffffff} x={32} y={32}>
Score: {score()}
</text><render-layer>
Wrap a subtree in a PixiJS RenderLayer. Useful for independent z-sorting, compositing, or post-processing passes without leaving JSX.
<render-layer zIndex={100}>
<text x={16} y={16}>
HUD Overlay
</text>
<container>
<sprite texture={hudTexture()} />
</container>
</render-layer>Frame-aware query functions src/engine/core/query-fns.ts
createSynchronizedEffect(query, effect, owner?)
Run when reactive state changes and commit changes during the next frame
- Tracks reactive dependencies in the query function.
- Queues the effect for the next PixiJS ticker frame and runs it with the most recent Ticker.
- Preserves the caller's SolidJS owner so cleanup and disposals behave as if the effect lived in the component.
onEveryFrame(effect)
Run on every frame
- Schedules effect on every ticker frame without reactive tracking.
- Ideal for fixed-step simulations, counters, and other continuous work.
- Prefer createSynchronizedEffect when you only need updates in response to state changes; reserve onEveryFrame for unavoidable per-frame work.
Core Framework Components
Application
The <Application> component constructs the intrinsic <application> tag,
and adds all necessary lifecycle management and core providers required for framework functionality.
There's usually no good reason to not use this component.
- Awaits
appInitializeand showsloadingState(defaults to<text>Loading...</text>) while asynchronous setup completes. - Starts the ticker only after the PixiJS application is ready and exposes the instance through
useApplicationState(). - Seeds the internal game-loop context so that
createSynchronizedEffectandonEveryFramecan schedule work onto the game loop. - Boots the PixiJS devtools overlay via
initDevtoolswhen available.
Use the intrinsic form (<application>) when authoring low-level JSX trees and the component form when you want the full runtime integration.
PixiExternalContainer
You may want to manage some or most of your logic outside the reactive render tree. For these cases, you may reach for PixiExternalContainer :
const external = new Container();
<PixiExternalContainer container={external} x={120} y={180}>
<text>Overlay UI</text>
</PixiExternalContainer>;- Adds reactivity around the managed container
Working with render layers
<render-layer> mounts a PixiJS RenderLayer alongside your display objects and automatically registers every descendant with that layer. The layer itself is inserted into the parent container, while the children remain regular siblings in the display list; they are simply marked as renderLayerChildren so PixiJS sorts and composites them through the layer. This means you can freely mix layered and non-layered content inside the same container without losing control over draw order.
- Any prop you pass to
<render-layer>(such assortableChildrenorzIndex) updates the underlying layer immediately, and reactive props will resync as signals change. - Descendants inherit the layer recursively: nested containers, sprites, text nodes, and additional
render-layerblocks all attach correctly, so complex hierarchies continue to render through the intended layer. - Conditional rendering,
For/Indexloops, and other SolidJS control flow work the same way—adding or removing nodes updates both the container's display list and the layer's child registry. - Removing the layer detaches the
RenderLayeritself and clears the associatedrenderLayerChildren, leaving the rest of the scene untouched.
<container>
<render-layer zIndex={100} sortableChildren>
<text x={16} y={16}>
HUD Overlay
</text>
<container>
<sprite texture={hudTexture()} />
</container>
<For each={alerts()}>
{(alert) => <text y={alert().y}>{alert().label}</text>}
</For>
</render-layer>
<sprite texture={playerTexture()} x={player().x} y={player().y} />
</container>The overlay subtree above always renders through the same layer, even as the alerts() array grows or shrinks, while the player sprite continues to follow normal container ordering.
Why the word "Sylph"?
Sprites, Pixi.js's... A reactive framework that's lighter than air.
