@kayelaa/canvas
v0.2.15
Published
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.
Maintainers
Keywords
Readme
@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)
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)
// 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();<!-- 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):
npm create vite@latest my-game -- --template vanilla-ts
cd my-game
npm install @kayelaa/canvas
npm run devNo Vite/Bundler? Try our ESM Build! Pure HTML + JS + Import maps
<!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)
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
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
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
useTick(() => {
entity.z = playerEntity.y > entity.y ? 10 : -10; // behind or in front of player
});useTick — Where all game logic lives
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
useTick((delta: number, event: KaylaEvent) => {
if (self.dead) {
event.preventDefault();
return;
}
// normal movement & AI
});usePaint — All your drawing code goes here
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
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
const isGameOver = useState<boolean>(false);
if (lives <= 0) {
isGameOver.set(true); // → full refresh → show game over UI, spawn particles
}Dangerous example — never do this
const pos = useState<Vector2>(new Vector2(400, 300));
// every frame pos.set(...) → game freezes in 2 secondsSafe & common use cases
isDead→ spawn death animation / particlescurrentLevel→ reload map / change musicweaponType→ swap sprite / fire rategameMode→ menu → playing → pause → game over
useRef — Cheap, persistent storage
const timer = useRef<number>(0);
useTick((delta) => {
timer.current += delta;
});Edge case — keep audio alive across refreshes
const sound = useRef<HTMLAudioElement | null>(null);
useEffect(() => {
sound.current = new Audio("hit.mp3");
return () => {
sound.current?.pause();
};
}, []);useExports — Let parents control you (with types)
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:
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
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 totsconfig.jsonorvite.config.ts:
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "@kayelaa/canvas"
}
}- No JSX? Use
createElementas fallback:
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
