Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/sanchedev/fraxel/llms.txt

Use this file to discover all available pages before exploring further.

Fraxel is built around three interlocking ideas: a node tree that maps directly to canvas draw calls, a signal-based reactivity model that updates only what changed, and a custom JSX runtime that lets you describe both using familiar component syntax. Understanding how these three pieces interact will make every other part of the engine click into place. The mental model is straightforward: JSX describes a tree of engine nodes, signals hold mutable state, and any JSX prop that references a signal automatically re-evaluates when that signal changes — without diffing, without re-rendering components, and without touching the DOM.

The Node Tree

Every JSX element in Fraxel maps directly to an engine class that knows how to update and draw itself on the HTML5 Canvas. There is no intermediate virtual representation — when you write <sprite textureId={TEX} />, you are constructing a Sprite node. The full list of built-in node types is defined in the PrimaryNode enum:
import { PrimaryNode } from 'fraxel'

// PrimaryNode.Group      — groups child nodes without positional management
// PrimaryNode.Transform  — positions and groups child nodes
// PrimaryNode.Sprite     — renders a texture
// PrimaryNode.Text       — renders text on the canvas
// PrimaryNode.Collider   — detects collisions
// PrimaryNode.RigidBody  — adds physics simulation
// PrimaryNode.Camera     — controls the viewport
// PrimaryNode.Clickable  — detects pointer events
// PrimaryNode.AnimationPlayer — plays frame-based sprite animations
// PrimaryNode.AudioPlayer     — plays audio buffers
// PrimaryNode.RayCast    — projects a ray to detect colliders
// PrimaryNode.Timer      — runs time-based callbacks
// PrimaryNode.Rectangle  — renders a filled or stroked rectangle

Hierarchy and Rendering Order

The tree structure determines both rendering order and spatial hierarchy. Child nodes are drawn on top of their parent, and their positions are relative to their parent’s position. The abstract Node2D base class translates the canvas context by position before drawing each child, so the coordinate system is automatically local:
<transform position={[100, 50]}>
  {/* This sprite is drawn at canvas position (100 + 20, 50 + 10) = (120, 60) */}
  <sprite textureId={PLAYER} position={[20, 10]} />
</transform>
Within a single parent, children are sorted by zIndex. A higher zIndex draws on top:
<transform>
  <sprite textureId={BACKGROUND} zIndex={0} />
  <sprite textureId={PLAYER} zIndex={1} />   {/* drawn on top */}
</transform>
The node tree has no connection to the browser DOM. There are no HTML elements created, no CSS, and no layout engine involved. The canvas element itself is the only DOM node Fraxel creates.

The Node Lifecycle

Every node follows the same lifecycle, driven by three methods called by the game loop:
MethodWhen it fires
start()Once, when the node is first added to an active scene
update(delta)Every frame, before drawing. delta is elapsed seconds since last frame.
draw(delta)Every frame, after update. Applies translation and renders to canvas.
destroy()When the node is explicitly removed from the tree
These lifecycle stages emit events (started, updated, drawed, destroyed) that hooks subscribe to. You never call these methods directly — the engine calls them and hooks let you react to them.

Node IDs and Child Lookup

Nodes can be assigned string IDs and located later by path. IDs must match the pattern [a-zA-Z][a-zA-Z0-9-_]*:
const container = useNode(PrimaryNode.Transform)

// After mount, find a deeply nested child by path:
useMount(() => {
  const child = container.node.child({
    path: ['hud', 'healthbar'],
    type: PrimaryNode.Sprite,
  })
})

return (
  <transform ref={container}>
    <transform id="hud">
      <sprite id="healthbar" textureId={HEALTHBAR_TEX} />
    </transform>
  </transform>
)

Signals & Reactivity

Fraxel’s reactivity model is fine-grained: state is held in signals, and only the canvas properties that depend on a changed signal are updated. There is no Virtual DOM, no component re-render, and no diffing.

useSignal

useSignal creates a reactive signal and returns a [getter, setter] tuple:
import { useSignal } from 'fraxel/hooks'

function HealthBar() {
  const [health, setHealth] = useSignal(100)

  // health is a getter function — call health() to read the value
  // setHealth(value) updates the signal and notifies all subscribers
  return (
    <sprite
      textureId={HEALTHBAR_TEX}
      // This prop is auto-computed: re-evaluated whenever health changes
      opacity={() => health() / 100}
    />
  )
}
The getter (health) is a plain TypeScript function. Calling health() returns the current value. Inside a JSX prop, an auto-computed effect, or a useEffect callback, calling health() also registers the signal as a dependency of that context.
Signal getters have a .value() method that reads the current value without registering a dependency. Use health.value() when you need the current value in a context where you do not want reactive tracking — for example, inside a one-time event handler.

Auto-Computed Props

Any JSX prop that receives a function (rather than a plain value) is treated as a computed reactive expression. Fraxel evaluates the function immediately to get the initial value, tracks which signals were read, and re-evaluates the function whenever any of those signals change:
const [health, setHealth] = useSignal(100)
const [speed, setSpeed] = useSignal(1)

return (
  <sprite
    textureId={PLAYER_TEX}
    // Recomputes only when health changes
    grayscale={() => (health() <= 0 ? 1 : 0)}
    // Recomputes only when health changes
    brightness={() => 0.5 + health() / 200}
    // Plain value — never recomputes
    position={[80, 50]}
  />
)
When setHealth(0) is called, only the grayscale and brightness canvas properties are updated. The position is not touched. This is the key difference from a Virtual DOM approach where the entire component output would be re-evaluated and compared.

useComputed

useComputed creates a derived signal — a read-only getter whose value is automatically recomputed whenever its dependencies change:
import { useSignal, useComputed } from 'fraxel/hooks'

const [x, setX] = useSignal(10)
const [y, setY] = useSignal(20)

// sum automatically recomputes when x or y changes
const sum = useComputed(() => x() + y())

useEffect(() => {
  console.log('Sum:', sum()) // 30
})

setX(15)
// sum() is now 35, and the effect above re-runs

useEffect

useEffect runs a side effect whenever the signals it reads change. Re-executions are batched — if multiple signals change synchronously in the same tick, the effect runs only once before the next frame:
import { useSignal, useEffect } from 'fraxel/hooks'

const [count, setCount] = useSignal(0)

useEffect(() => {
  console.log('Count changed:', count())
  return () => {
    // Optional cleanup, runs before the next effect execution
    // and when the node is destroyed
    console.log('Cleanup')
  }
})

return <transform />
useEffect batches re-runs using queueMicrotask. This means all synchronous signal updates in a single frame settle before any effect fires, preventing redundant work.

The JSX Runtime

Fraxel ships its own JSX transform under the fraxel/jsx-runtime path. When you set jsxImportSource: "fraxel" in your tsconfig.json, TypeScript’s automatic JSX transform imports from fraxel/jsx-runtime instead of react/jsx-runtime. This means every .tsx file in your project uses the Fraxel engine as its JSX factory. There is no React import needed, and there is no React anywhere in the bundle.

How JSX Becomes Nodes

JSX elements fall into two categories in Fraxel:
  1. Lowercase elements (<transform>, <sprite>, <collider>) — map to built-in engine node constructors. These are resolved at startup when createGame processes the JSX tree.
  2. Capitalized components (<Player>, <Enemy>) — are plain TypeScript functions that return more JSX. They are called immediately during tree construction, not re-called on updates.
// This JSX:
const scene = () => (
  <transform>
    <Player />
    <sprite textureId={BG} />
  </transform>
)

// Is roughly equivalent to constructing:
// new TransformNode({
//   children: [
//     ...Player(),          // Player() returns more nodes
//     new SpriteNode({ textureId: BG })
//   ]
// })
Because components are called once at scene load time, there is no concept of “re-rendering” a component in Fraxel. Reactivity happens at the prop level via signals, not at the component level via re-invocation.

createGame and Scene Loading

createGame is the entry point that bridges the JSX declaration and the running engine:
import { createGame, Game, Scene } from 'fraxel/jsx'

const game = createGame(
  <Game width={192} height={112} defaultScene="main">
    <Scene name="main" component={scene} />
  </Game>,
  document.querySelector('#root')!,
)
Internally, createGame:
  1. Calls Game.setup() with the canvas dimensions and mounts the canvas inside the root element
  2. Registers each <Scene> with the SceneManager
  3. Calls SceneManager.setScene(defaultScene) to queue the initial scene
  4. Starts the requestAnimationFrame loop via Game.play()
The <Game> and <Scene> components themselves return null — they exist only to carry configuration as JSX props, which createGame reads before discarding the JSX tree.

Hooks

Hooks are the primary way to attach behavior to nodes in Fraxel. They must be called inside a component function (not inside event handlers or async callbacks).
HookPurpose
useSignal(initial)Creates a reactive [getter, setter] pair
useComputed(fn)Derives a read-only signal from other signals
useEffect(fn)Runs a side effect when dependencies change
useMount(fn)Runs once on node start; returns optional cleanup
useNode(type)Creates a typed node reference for use with ref prop
useEvent(ref, event, fn)Subscribes to a node lifecycle event
useSpawn(ref)Returns a function that dynamically adds child nodes
useGame()Gets the GameControls object for scene switching and pause
useChild(path, type)Retrieves a descendant node by path after mount

Lifecycle Hooks

useMount is the simplest lifecycle hook. It runs once when the node starts, and optionally returns a cleanup function that runs on destroy:
import { useMount } from 'fraxel/hooks'

function Enemy() {
  useMount(() => {
    console.log('Enemy spawned')
    return () => {
      console.log('Enemy destroyed')
    }
  })

  return <sprite textureId={ENEMY_TEX} />
}
useEvent subscribes to any node event by name. This is the idiomatic way to hook into the per-frame updated cycle for movement and game logic:
import { useNode, useEvent } from 'fraxel/hooks'
import { PrimaryNode } from 'fraxel'

function Ball() {
  const transform = useNode(PrimaryNode.Transform)

  useEvent(transform, 'updated', (delta) => {
    transform.node.position.x += delta * 100
  })

  return (
    <transform ref={transform}>
      <sprite textureId={BALL_TEX} />
    </transform>
  )
}

Scenes

Scenes are the top-level containers for game content. Each scene has a name and a component function that returns a node tree. The SceneManager handles loading, unloading, and switching between scenes.

Defining Scenes

Scenes are registered through the <Scene> JSX element inside <Game>:
// Eager loading — component is a plain function
<Scene name="menu" component={MenuScene} />

// Lazy loading — component is an async import that returns the scene function
<Scene name="game" component={() => import('./scenes/game.js')} />
Lazy loading is recommended for large scenes because SceneManager will only load the scene’s module when it is first activated.

Switching Scenes

Use useGame() inside any component to get the GameControls object, then call changeScene:
import { useGame } from 'fraxel/hooks'

function MainMenu() {
  const game = useGame()

  return (
    <clickable
      size={[80, 20]}
      onClick={() => game.changeScene('gameplay')}
    />
  )
}
changeScene returns a Promise<void> that resolves once the new scene is fully loaded and active. For seamless transitions, use preloadScene to load the next scene in the background while the current one is still running:
const game = useGame()

// Start preloading while the current scene plays
useMount(async () => {
  const activate = await game.preloadScene('level-2')
  // Later, switch instantly — already loaded
  activate()
})

Scene Controls Reference

interface GameControls {
  play: () => void
  pause: () => void
  changeScene: (name: string) => Promise<void>
  preloadScene: (name: string) => Promise<() => void>
  getSize: () => Vector2
}

Scripts

FraxelScript is an optional class-based system for separating game logic from the JSX component tree. Attach a script to any node via the script prop:
import { FraxelScript } from 'fraxel/scripts'
import { PrimaryNode } from 'fraxel'

class PlayerScript extends FraxelScript<PrimaryNode.Transform> {
  health = 100

  // setup() is called automatically when the node starts.
  // Use connect() to subscribe to node events in a type-safe way.
  setup() {
    this.connect('started', () => {
      console.log('Player spawned!')
    })

    this.connect('updated', (delta) => {
      // this.me is the TransformNode this script is attached to
      this.me.position.x += delta * 50
    })
  }

  applyDamage(amount: number) {
    this.health -= amount
    if (this.health <= 0) this.me.destroy()
  }
}
Attach a script instance to a node via the script prop:
const playerScript = new PlayerScript()

function Player() {
  return (
    <transform script={playerScript}>
      <sprite textureId={PLAYER_TEX} />
    </transform>
  )
}
Scripts are best suited for self-contained game objects that need to expose a public API (like applyDamage) to other parts of the game. For most reactive UI and animation work, hooks inside the component function are simpler and more composable.
connect(eventName, callback) provides the same type-safe event subscription as useEvent, but scoped to the node the script is attached to via this.me. The script’s lifecycle matches the node it is attached to — when the node is destroyed, the script’s event listeners are cleaned up automatically.

Build docs developers (and LLMs) love