npm package: @kayelaa/canvas
Description: Standalone 2D game logic & entity library with declarative JSX + hooks (useTick, useRect, useSelf). Zero VDOM. LEA-powered ticker & scenes. Runs safely in Node.js / headless (no renderer needed) for server-side simulation + browser html5 canvas games.
Author: Kayelaa Cagara
Latest version: 0.2.19

# @kayelaa/canvas

**Standalone 2D game logic & entity library with declarative JSX + hooks (useTick, useRect, useSelf). Zero VDOM. LEA-powered ticker & scenes. Runs safely in Node.js / headless (no renderer needed) for server-side simulation + browser html5 canvas games.**

This library gives you:
- JSX-like syntax to spawn and organize game objects (players, enemies, bullets, UI, particles)
- Hooks (`useTick`, `usePaint`, `useSelf`, `useEntity`, `useState`, `useRef`, `useEffect`, `useExports`) that feel familiar but are **game-optimized**
- Direct access to LEA's fast imperative core (positions, drawing, mouse, z-order, collisions)
- Built-in audio helpers (`LiaAudio`, `LiaOscSFX`) for UI/game sound effects
- Math & utility tools (`Vector2`, easing functions, raycasting, serializers, etc.)

**Important:** This is **not** React-for-canvas.  
There is **no virtual DOM**, **no per-frame re-renders**, **no prop diffing**.  
It's a **very thin declarative layer** on top of LEA — all performance-critical code stays **imperative** and **direct**.

Made by **Kayelaa Cagara** (@LianeKayee39 on X / GitHub) in Calamba, Philippines.

## API Reference for missing things in this docs (worth checking if you want a true API reference for every variables, properties, etc)
[API Reference](https://lianecagara.github.io/kayelaa-canvas-docs/)

**The Documentation below would get INACCURATE in the future. Always visit the .d.ts files or the API Reference for reliable documentation.**

## Quick Start — Fully Typed Version (copy-paste this)

```tsx
// demo.ts v1.3
/** @jsxImportSource @kayelaa/canvas */
import {
  createGame,
  createScene,
  createRenderer,
  useSelf,
  useTick,
  usePaint,
  FCProps,
  useRef,
  Kayla,
  FCExports,
  useExports,
  useRect,
  useFiberControl,
  useInitialization,
  LEA,
  useGlobalClick,
  useClick,
  useCurrentTicker,
  createContext,
  KaylaRect,
  useContext,
} from "@kayelaa/canvas/kayla";

interface MouseFollowerProps extends FCProps {
  initialX?: number;
  initialY?: number;
  color: string;
  initialSpeed?: number;
  freeXY?: boolean;
}

interface MouseFollowerExports extends FCExports {
  jump(): void;
  getHealth(): number;
  rect: KaylaRect;
  health: number;
}

const MainContext = createContext<{
  members: KaylaRect[];
  player: MouseFollowerExports | null;
}>(null);

const MouseFollower: Kayla.FC<MouseFollowerProps, MouseFollowerExports> = ({
  initialX = 0,
  initialY = 0,
  color,
  initialSpeed,
  freeXY = true,
}) => {
  const rect = useRect();
  interface CableVecInfo {
    remainingMS: number;
    vec: LEA.Vector2;
    totalDist: number;
  }
  const cableVecs = useRef<CableVecInfo[]>([]);
  const cableConfig = useSelf(() => ({
    durationMS: 1000,
  }));

  useGlobalClick((pos, type) => {
    if (rect.isHovered()) return;
    if (type === "left") {
      const start = rect.pos.clone();
      cableVecs.current.push({
        remainingMS: cableConfig.durationMS,
        vec: pos.clone(),
        totalDist: pos.subtract(start).length,
      });
    }
  });

  useTick((delta) => {
    const remaining: typeof cableVecs.current = [];
    let newestTotalDist = cableVecs.current.at(-1)?.totalDist ?? 0;
    for (const ref of cableVecs.current) {
      if (ref.remainingMS <= 0.01) continue;
      const { vec: target } = ref;
      const dir = target.subtract(rect.pos);
      const moveLen = (newestTotalDist / cableConfig.durationMS) * delta * 1000;
      const added = dir.normalized().scale(moveLen);
      rect.x += added.x;
      rect.y += added.y;
      ref.remainingMS -= delta * 1000;
      if (ref.remainingMS > 0.01) {
        remaining.push(ref);
      }
    }
    cableVecs.current = remaining;
  });

  const fiber = useFiberControl();
  const ticker = useCurrentTicker();
  const mainContext = useContext(MainContext);

  useInitialization(() => {
    if (freeXY) {
      rect.x = initialX;
      rect.y = initialY;
      rect.width = 40;
      rect.height = 40;
      rect.z = 100;
    }

    console.log(`New Follower: ${fiber.key}`);
    console.log({ mems: mainContext.members });
    mainContext.player = me;
    return () => {
      console.log(`Removed Follower: ${fiber.key}`);
      mainContext.player = null;
    };
  });

  useClick(() => {
    const a = 40;
    rect.width += a;
    rect.height += a;
    ticker.createTween(
      {
        ms: 3000,
        easing: LEA.LeaUtilsII.easeOutExpo,
        delta: -a,
      },
      (da) => {
        rect.width += da;
        rect.height += da;
      },
    );
  });

  const me: MouseFollowerExports = useSelf(() => ({
    speed: initialSpeed ?? LEA.LeaUtilsII.randomLerp(150, 300),
    health: 100,
    getHealth() {
      return this.health;
    },
    get rect() {
      return rect;
    },
    jump() {
      rect.y -= 100;
    },
  }));

  usePaint((ctx) => {
    ctx.globalAlpha = 1;
    ctx.strokeStyle = "white";
    ctx.lineWidth = 10;
    for (const { vec: cable } of cableVecs.current) {
      ctx.beginPath();
      ctx.moveTo(rect.x, rect.y);
      ctx.lineTo(cable.x, cable.y);
      ctx.stroke();
    }
    ctx.fillStyle = color ?? (me.health > 50 ? "#00aaff" : "#ff4444");
    ctx.fillRect(rect.left, rect.top, rect.width, rect.height);
  });

  useExports(MouseFollower, () => me);
};

// Setup (run once)
const canvas = document.getElementById("game") as HTMLCanvasElement;
const renderer = createRenderer(canvas);
renderer.listenPointerUpdates();

const game = createGame({ width: 800, height: 600, updateHz: 1000 / 60 });
const scene = createScene("main");

renderer.attachTo(game);
scene.attachTo(game);

scene.spawn(
  <MainContext.Provider value={{ members: [], player: null }}>
    <MouseFollower initialX={400} initialY={300} color="red" />
  </MainContext.Provider>,
);

game.start();
```

```html
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <title>My Game</title>
  <style>
    body { margin: 0; background: #000; }
    canvas { display: block; }
  </style>
</head>
<body>
  <canvas id="game" width="800" height="600"></canvas>
  <script type="module" src="/main.ts"></script>
</body>
</html>
```

Run with Vite (recommended):

```bash
npm create vite@latest my-game -- --template vanilla-ts
cd my-game
npm install @kayelaa/canvas
npm run dev
```

## No Vite/Bundler? Try our ESM Build! Pure HTML + JS + Import maps

```html
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <title>Kayla Canvas Test - No JSX</title>
  <style>
    body {
      margin: 0;
      overflow: hidden;
    }

    canvas {
      display: block;
      background: #111;
    }
  </style>

  <script type="importmap">
  {
    "imports": {
      "@kayelaa/canvas/kayla": "https://cdn.jsdelivr.net/npm/@kayelaa/canvas@latest/dist/kayla.js"
    }
  }
  </script>
</head>

<body>
  <canvas id="game" width="800" height="600"></canvas>

  <script type="module">
    // demo.ts v1.3
    // @ts-nocheck
    /** @jsxImportSource @kayelaa/canvas */
    import {
      createGame,
      createScene,
      createRenderer,
      useSelf,
      useTick,
      usePaint,
      useRef,
      Kayla,
      useExports,
      useRect,
      useFiberControl,
      useInitialization,
      LEA,
      useGlobalClick,
      useClick,
      useCurrentTicker,
      createContext,
      KaylaRect,
      useContext,
      createElement,
    } from "@kayelaa/canvas/kayla";

    const MainContext = createContext(null);

    const MouseFollower = ({
      initialX = 0,
      initialY = 0,
      color,
      initialSpeed,
      freeXY = true,
    }) => {
      const rect = useRect();

      const cableVecs = useRef([]);
      const cableConfig = useSelf(() => ({
        durationMS: 1000,
      }));

      useGlobalClick((pos, type) => {
        if (rect.isHovered()) return;
        if (type === "left") {
          const start = rect.pos.clone();
          cableVecs.current.push({
            remainingMS: cableConfig.durationMS,
            vec: pos.clone(),
            totalDist: pos.subtract(start).length,
          });
        }
      });

      useTick((delta) => {
        const remaining = [];
        let newestTotalDist = cableVecs.current.at(-1)?.totalDist ?? 0;
        for (const ref of cableVecs.current) {
          if (ref.remainingMS <= 0.01) continue;
          const { vec: target } = ref;
          const dir = target.subtract(rect.pos);
          const moveLen = (newestTotalDist / cableConfig.durationMS) * delta * 1000;
          const added = dir.normalized().scale(moveLen);
          rect.x += added.x;
          rect.y += added.y;
          ref.remainingMS -= delta * 1000;
          if (ref.remainingMS > 0.01) {
            remaining.push(ref);
          }
        }
        cableVecs.current = remaining;
      });

      const fiber = useFiberControl();
      const ticker = useCurrentTicker();
      const mainContext = useContext(MainContext);

      useInitialization(() => {
        if (freeXY) {
          rect.x = initialX;
          rect.y = initialY;
          rect.width = 40;
          rect.height = 40;
          rect.z = 100;
        }

        console.log(`New Follower: ${fiber.key}`);
        console.log({ mems: mainContext.members });
        mainContext.player = me;
        return () => {
          console.log(`Removed Follower: ${fiber.key}`);
          mainContext.player = null;
        };
      });

      useClick(() => {
        const a = 40;
        rect.width += a;
        rect.height += a;
        ticker.createTween(
          {
            ms: 3000,
            easing: LEA.LeaUtilsII.easeOutExpo,
            delta: -a,
          },
          (da) => {
            rect.width += da;
            rect.height += da;
          },
        );
      });

      const me = useSelf(() => ({
        speed: initialSpeed ?? LEA.LeaUtilsII.randomLerp(150, 300),
        health: 100,
        getHealth() {
          return this.health;
        },
        get rect() {
          return rect;
        },
        jump() {
          rect.y -= 100;
        },
      }));

      usePaint((ctx) => {
        ctx.globalAlpha = 1;
        ctx.strokeStyle = "white";
        ctx.lineWidth = 10;
        for (const { vec: cable } of cableVecs.current) {
          ctx.beginPath();
          ctx.moveTo(rect.x, rect.y);
          ctx.lineTo(cable.x, cable.y);
          ctx.stroke();
        }
        ctx.fillStyle = color ?? (me.health > 50 ? "#00aaff" : "#ff4444");
        ctx.fillRect(rect.left, rect.top, rect.width, rect.height);
      });

      useExports(MouseFollower, () => me);
    };

    // Setup (run once)
    const canvas = document.getElementById("game");
    const renderer = createRenderer(canvas);
    renderer.listenPointerUpdates();

    const game = createGame({ width: 800, height: 600, updateHz: 1000 / 60 });
    const scene = createScene("main");

    renderer.attachTo(game);
    scene.attachTo(game);

    scene.spawn(
      createElement(MainContext.Provider, {
        value: {
          members: [],
          player: null,
        },
        children: [
          createElement(MouseFollower, {
            initialX: 400,
            initialY: 300,
            color: "red",
          }),
        ],
      }),
    );

    game.start();
  </script>
</body>

</html>
```

## Core Rules — Read This First or Your Game Will Lag

These rules are **not suggestions**. Breaking them causes serious performance problems in real games.

| Do this                                                                 | Don't do this                                                                 | Why it matters (seriously) |
|-------------------------------------------------------------------------|-------------------------------------------------------------------------------|-----------------------------|
| Define typed props with `interface MyProps extends FCProps { ... }`     | Pass props without types — let TS infer everything                            | Better autocompletion, catches mistakes early |
| Use `FC<Props, Exports>` for components that expose methods             | Forget to type exports — parent gets `any`                                    | Parent can safely call `jump()`, `takeDamage()` |
| Mutate `entity.x`, `entity.y`, `entity.z` directly                      | Duplicate position in `self.pos` unless needed                                | LEA uses real position for sorting/collision — duplication = bugs + slowdown |
| Put mutable game logic/state in `useSelf`                               | Put velocity/health/timers in `useState`                                      | `useState` refresh = full re-run + child re-spawn → lag + GC pressure |
| Use `key="enemy-${id}"` on dynamic lists                                | Spawn `<Enemy />` in loop without `key`                                       | No key = no pooling/reuse → memory leak + thrashing |
| Check `exportsRef.current !== null` before use                          | Assume child always exists after spawn                                        | Child can die → ref becomes stale → crash |
| Use `event.preventDefault()` when paused/dead                           | Run full AI/physics when game-over screen is visible                          | Wastes CPU for invisible work |
| Return **clean, typed public API** from `useExports`                    | Return entire `self` or `entity` to parent                                    | Breaks encapsulation — parent can break internals |

## The Main Hooks — Explained with Real, Typed Code Snippets

### useSelf — Your main stable god-object (use this for almost everything)

```typescript
const self = useSelf(() => ({
  // This object never gets recreated — mutate it freely
  health: 100,
  pos: new Vector2(400, 300),
  vel: new Vector2(0, -200),

  tick(delta: number) {
    this.pos.x += this.vel.x * delta;
    this.pos.y += this.vel.y * delta;

    if (this.pos.y < 0) this.vel.y *= -1; // bounce top
    if (this.health <= 0) {
      // death logic — no refresh needed here
    }
  }
}));

useTick((delta: number) => self.tick(delta));
```

**Edge case — one-time initialization with ref guard**

```ts
const self = useSelf(() => {
  const audio = new Audio("bgm.mp3");
  audio.loop = true;
  audio.volume = 0.3;
  return { audio, initialized: true };
});
```

### useEntity — Direct access to the real LEA entity

```ts
const entity = useEntity();

useTick(() => {
  entity.current.x += 5;           // move right
  entity.current.z = entity.current.y;     // sort by Y position (higher = drawn later)
  entity.current.width = 64;       // resize dynamically
});
```

**Edge case — dynamic z-layer based on parent**

```ts
useTick(() => {
  entity.z = playerEntity.y > entity.y ? 10 : -10; // behind or in front of player
});
```

### useTick — Where all game logic lives

```ts
useTick((delta: number, event: KaylaEvent) => {
  if (isPaused) {
    event.preventDefault(); // skip rest of tick for this object
    return;
  }

  entity.x += speed * delta;
});
```

**Edge case — completely skip when dead**

```ts
useTick((delta: number, event: KaylaEvent) => {
  if (self.dead) {
    event.preventDefault();
    return;
  }
  // normal movement & AI
});
```

### usePaint — All your drawing code goes here

```ts
usePaint((ctx: CanvasRenderingContext2D, event: KaylaEvent) => {
  ctx.fillStyle = self.health > 50 ? "#00ff00" : "#ff4444";
  ctx.fillRect(entity.x - 20, entity.y - 20, 40, 40);
});
```

**Edge case — disable LEA's default rectangle fill**

```ts
usePaint((ctx: CanvasRenderingContext2D, event: KaylaEvent) => {
  event.preventDefault(); // skip LEA auto-draw
  // your custom sprite / circle / text
  ctx.beginPath();
  ctx.arc(entity.x, entity.y, 20, 0, Math.PI * 2);
  ctx.fillStyle = "red";
  ctx.fill();
});
```

### useState — Only for rare structural changes

```ts
const isGameOver = useState<boolean>(false);

if (lives <= 0) {
  isGameOver.set(true); // → full refresh → show game over UI, spawn particles
}
```

**Dangerous example — never do this**

```ts
const pos = useState<Vector2>(new Vector2(400, 300));
// every frame pos.set(...) → game freezes in 2 seconds
```

**Safe & common use cases**
- `isDead` → spawn death animation / particles
- `currentLevel` → reload map / change music
- `weaponType` → swap sprite / fire rate
- `gameMode` → menu → playing → pause → game over

### useRef — Cheap, persistent storage

```ts
const timer = useRef<number>(0);
useTick((delta) => {
  timer.current += delta;
});
```

**Edge case — keep audio alive across refreshes**

```ts
const sound = useRef<HTMLAudioElement | null>(null);

useEffect(() => {
  sound.current = new Audio("hit.mp3");
  return () => {
    sound.current?.pause();
  };
}, []);
```

### useExports — Let parents control you (with types)

```ts
interface PlayerExports {
  jump(): void;
  takeDamage(amount: number): void;
  getHealth(): number;
}

const Player: FC<PlayerProps, PlayerExports> = () => {
  // ...

  useExports(Player, () => ({
    jump() {
      self.vel.y = -400;
    },
    takeDamage(amount: number) {
      self.health -= amount;
    },
    getHealth() {
      return self.health;
    }
  }));
};
```

Parent usage:

```ts
const playerApi = useRef<PlayerExports | null>(null);
<Player exportsRef={playerApi} />

useTick(() => {
  if (playerApi.current) {
    playerApi.current.jump();
  }
});
```

**Best practice** — always type exports and return clean API

```ts
useExports(Player, () => ({
  jump: self.jump,
  takeDamage: (amt: number) => { self.health -= amt; },
  // defensive copy to prevent parent mutation
  getPosition: (): { x: number; y: number } => ({ x: entity.x, y: entity.y })
}));
```

## Do / Don't Table — Semantic Best Practices

| Do this                                                                 | Don't do this                                                                 | Why it matters (semantic & performance) |
|-------------------------------------------------------------------------|-------------------------------------------------------------------------------|-----------------------------------------|
| Define `interface MyProps extends FCProps { ... }`                      | Pass props without types — let TS infer everything                            | Better autocompletion, catches mistakes early |
| Use `FC<Props, Exports>` when component exposes methods                 | Forget to type exports — parent gets `any`                                    | Parent can safely call `jump()`, `takeDamage()` |
| Mutate `entity.x`, `entity.y`, `entity.z` directly                      | Duplicate position in `self.pos` unless needed                                | LEA uses real position for sorting/collision — duplication = bugs + slowdown |
| Put mutable game logic/state in `useSelf`                               | Put velocity/health/timers in `useState`                                      | `useState` refresh = full re-run + child re-spawn → lag + GC pressure |
| Use `key="enemy-${id}"` on dynamic lists                                | Spawn `<Enemy />` in loop without `key`                                       | No key = no pooling/reuse → memory leak + thrashing |
| Check `exportsRef.current !== null` before use                          | Assume child always exists after spawn                                        | Child can die → ref becomes stale → crash |
| Use `event.preventDefault()` when paused/dead                           | Run full AI/physics when game-over screen is visible                          | Wastes CPU for invisible work |
| Return **clean, typed public API** from `useExports`                    | Return entire `self` or `entity` to parent                                    | Breaks encapsulation — parent can break internals |

## Compatibility & Tooling Notes

- **Primary target**: Browser + Vite (vanilla TS or JS)
- **React coexistence**: Yes — works side-by-side without conflict.  
  Just add to `tsconfig.json` or `vite.config.ts`:

```json
{
  "compilerOptions": {
    "jsx": "react-jsx",
    "jsxImportSource": "@kayelaa/canvas"
  }
}
```

- **No JSX?** Use `createElement` as fallback:

```ts
scene.spawn(createElement(Player, { speed: 300 }));
```

- **Node.js**: Non-canvas parts (`Vector2`, math utils, serializers, timeouts, tickers) work fine.  
  Canvas/audio parts throw or do nothing (no DOM/Web Audio API).

- **Typescript**: Make sure to use this with typescript tooling for enhanced descriptions/documentation in the IDE.

Happy building!  
If you make something cool, tag me on X @LianeKayee39 — I’d love to see it.

Made with love in Calamba, Philippines — 2026  
Kayelaa Cagara