Skip to main content
Version: 0.x.x

Drawing & Layers

The React package provides declarative draw components that automatically manage layer registration and rendering. All components use world coordinates, and the engine handles scaling and positioning.

Type Safety

All drawing types are exported from the package. See Types Reference for complete type definitions.

import {
Rect,
Circle,
Text,
Path,
ImageItem,
Coords,
} from "@canvas-tile-engine/react";

Layer System

Layers control the Z-order of your content. Lower numbers draw first (background), higher numbers draw last (foreground).

LayerTypical Usage
0Background / Terrain
1Grid lines / Floor decorations
2Objects / Units / Buildings
3UI Markers / Overlays

Shapes

<Rect> & <Circle>

Draw basic geometric shapes. Pass a single object or an array for batch rendering.

PropTypeDefaultDescription
itemsRect | Rect[]RequiredShape definitions (for <Rect>).
itemsCircle | Circle[]RequiredShape definitions (for <Circle>).
layernumber1Rendering layer.
styleOf(item) => style | undefined-Paint-time decoration for selection/hover; see Styling by State.
visibleOf(item) => boolean | undefined-Per-item show/hide: false skips the item (not painted, not hit-testable); see Visibility and Interactivity by State.
interactiveOf(item) => boolean | undefined-Per-item hit-test opt-out: false keeps the item painted but transparent to hit queries.

Rect / Circle Properties:

PropertyTypeDefaultDescription
x, ynumberRequiredWorld coordinates.
sizenumber1Size in grid units.
sizePxnumber-Fixed diameter in screen pixels, independent of zoom — marker dots (only for Circle, analog of Text's fontPx). Wins over size. Ignored by StaticCircle.
styleobject{}Styling options.
originobject{ mode: "cell", x: 0.5, y: 0.5 }Anchor point.
widthnumbersizeWidth in world units (only for Rect). Combine with height for non-square rectangles: bars, cards, zone floors.
heightnumbersizeHeight in world units (only for Rect).
rotatenumber0Rotation angle in degrees (only for Rect).
radiusnumber | number[]-Border radius in world units (scales with zoom). Single value for all corners, or [topLeft, topRight, bottomRight, bottomLeft] (only for Rect).
dataTData-Arbitrary app data. Never read by the engine; returned on hitTest results as hit.item.data to identify what was hit.

Style Options:

  • fillStyle: Fill color (e.g., "#ff0000", "rgba(0,0,0,0.5)")
  • strokeStyle: Border color
  • lineWidth: Border width in world units; scales with zoom like the shape
  • lineWidthPx: Border width in screen pixels, independent of zoom; wins over lineWidth
  • lineDash: Border dash pattern in world units; dashes scale with zoom. Canvas2D setLineDash semantics; omit for a solid border
  • lineDashPx: Border dash pattern in screen pixels, independent of zoom; wins over lineDash
<CanvasTileEngine
engine={engine}
config={config}
renderer={new RendererCanvas()}
>
{/* Blue square */}
<CanvasTileEngine.Rect
items={{ x: 5, y: 5, size: 1, style: { fillStyle: "#0077be" } }}
layer={1}
/>

{/* Rotated rectangle */}
<CanvasTileEngine.Rect
items={{
x: 8,
y: 5,
size: 1,
rotate: 45,
style: { fillStyle: "#ff6b6b" },
}}
layer={1}
/>

{/* Rounded rectangle */}
<CanvasTileEngine.Rect
items={{
x: 10,
y: 5,
size: 1,
radius: 0.15,
style: { fillStyle: "#2ecc71" },
}}
layer={1}
/>

{/* Non-square rectangle: a 4x2 zone floor */}
<CanvasTileEngine.Rect
items={{
x: 5,
y: 8,
width: 4,
height: 2,
style: { fillStyle: "rgba(34, 197, 94, 0.3)" },
}}
layer={1}
/>

{/* Different corner radii */}
<CanvasTileEngine.Rect
items={{
x: 12,
y: 5,
size: 1,
radius: [0.2, 0, 0.2, 0],
style: { fillStyle: "#9b59b6" },
}}
layer={1}
/>

{/* Red circle */}
<CanvasTileEngine.Circle
items={{ x: 6, y: 5, size: 0.8, style: { fillStyle: "#e63946" } }}
layer={2}
/>

{/* Batch rendering */}
<CanvasTileEngine.Rect
items={[
{ x: 10, y: 10, style: { fillStyle: "blue" } },
{ x: 12, y: 10, style: { fillStyle: "green" } },
{ x: 14, y: 10, style: { fillStyle: "red" } },
]}
layer={1}
/>
</CanvasTileEngine>

Lines & Paths

<Line>

Draw straight lines between two points.

PropTypeDefaultDescription
itemsLine | Line[]RequiredLine definitions.
styleLineStyle-Default line style; an item's own style overrides it per item.
styleOf(item) => style | undefined-Per-item decoration overlaid on both (color/dash only); see Styling by State.
visibleOf(item) => boolean | undefined-Per-item show/hide: false skips the item (not painted, not hit-testable).
interactiveOf(item) => boolean | undefined-Per-item hit-test opt-out: false keeps the item painted but transparent to hit queries.

Line Properties: { from: { x, y }, to: { x, y }, style?: LineStyle, data?: TData } — an item's style overrides the style prop unit pair by unit pair and, being registration-time, may change the stroke width (hit testing follows it).

{
/* Single line */
}
<CanvasTileEngine.Line
items={{ from: { x: 0, y: 0 }, to: { x: 10, y: 10 } }}
style={{ strokeStyle: "#fb8500", lineWidthPx: 3 }}
layer={1}
/>;

{
/* Mixed styles in one batch: item style overrides the prop default */
}
<CanvasTileEngine.Line
items={[
{ from: { x: 0, y: 0 }, to: { x: 5, y: 5 } },
{ from: { x: 5, y: 0 }, to: { x: 0, y: 5 }, style: { strokeStyle: "#f59e0b", lineWidthPx: 4 } },
]}
style={{ strokeStyle: "red", lineWidthPx: 2 }}
layer={1}
/>;

<Path>

Draw free-form paths: open polylines, closed outlines, and filled shapes. Each PathItem owns its geometry and style.

PropTypeDefaultDescription
itemsPathItem | PathItem[]RequiredPath definitions.
layernumber1Rendering layer.
styleOf(item) => style | undefined-Paint-time decoration (no stroke width / corner radius); see Styling by State.
visibleOf(item) => boolean | undefined-Per-item show/hide: false skips the item (not painted, not hit-testable).
interactiveOf(item) => boolean | undefined-Per-item hit-test opt-out: false keeps the item painted but transparent to hit queries.

PathItem: { commands?, points?, closed?, fillRule?, style?, data? }commands is a Canvas2D-style command list (curves, arcs, multiple subpaths, holes); points is the polyline form. See the core drawing docs for the full PathCommand, property, and PathStyle tables. Filled paths hit-test on their interior (holes excluded), unfilled ones on the stroke itself.

{
/* Open route line */
}
<CanvasTileEngine.Path
items={{
points: [
{ x: 0, y: 0 },
{ x: 5, y: 0 },
{ x: 5, y: 5 },
],
style: { strokeStyle: "#219ebc", lineWidthPx: 2 },
}}
layer={1}
/>;

{
/* Filled zone with a rounded outline and hit-test data */
}
<CanvasTileEngine.Path
items={{
points: zoneOutline,
closed: true,
style: { fillStyle: "#22c55e55", strokeStyle: "#166534", lineWidthPx: 2, cornerRadius: 0.5 },
data: { id: "zone-a" },
}}
layer={1}
/>;

{
/* Multiple items, each with its own style */
}
<CanvasTileEngine.Path
items={[
{ points: routeA, style: { strokeStyle: "#219ebc", lineWidthPx: 2 } },
{ points: routeB, style: { strokeStyle: "green", lineWidthPx: 1 } },
]}
layer={1}
/>;

Keep items referentially stable (useMemo/state) — a new array identity re-registers the draw callback. Appearance-only changes (selection, hover) belong in styleOf, which never re-registers.

<GridLines>

Draw grid lines at specified intervals.

PropTypeDefaultDescription
cellSizenumberRequiredSize of each grid cell in world units.
lineWidthnumber1Width of grid lines in pixels.
strokeStylestring"black"Color of the grid lines.
layernumber0Rendering layer.
{/* Basic grid */}
<CanvasTileEngine.GridLines cellSize={1} />

{/* Styled grid */}
<CanvasTileEngine.GridLines
cellSize={5}
lineWidth={2}
strokeStyle="rgba(255, 255, 255, 0.3)"
layer={0}
/>

{/* Multiple grid scales */}
<CanvasTileEngine.GridLines cellSize={1} lineWidth={0.5} strokeStyle="rgba(0,0,0,0.1)" layer={0} />
<CanvasTileEngine.GridLines cellSize={5} lineWidth={1} strokeStyle="rgba(0,0,0,0.3)" layer={0} />
<CanvasTileEngine.GridLines cellSize={50} lineWidth={2} strokeStyle="rgba(0,0,0,0.5)" layer={0} />

Text & Images

<Text>

Render text at world coordinates. Text size scales with zoom.

PropTypeDefaultDescription
itemsText | Text[]RequiredText definitions.
layernumber2Rendering layer.
styleOf(item) => style | undefined-Paint-time decoration for selection/hover; see Styling by State.
visibleOf(item) => boolean | undefined-Per-item show/hide: false skips the item for the frame (text never hit-tests, so there is no interactiveOf).

Text Properties:

PropertyTypeDefaultDescription
x, ynumberRequiredWorld coordinates.
textstringRequiredThe text content.
sizenumber1Font size in world units (scales with zoom). Ignored when fontPx is set.
fontPxnumber-Fixed font size in pixels, independent of zoom. Takes precedence over size.
styleobject-Font styling options.
rotatenumber0Rotation angle in degrees (clockwise).

Style Options:

  • fillStyle: Text color
  • fontFamily: Font family (default: "sans-serif")
  • textAlign: "left", "center", "right"
  • textBaseline: "top", "middle", "bottom"
{
/* Single text */
}
<CanvasTileEngine.Text
items={{
x: 5,
y: 5,
text: "Base Camp",
size: 1,
style: { fillStyle: "white", fontFamily: "Arial" },
}}
layer={3}
/>;

{
/* Rotated text (45 degrees) */
}
<CanvasTileEngine.Text
items={{
x: 8,
y: 5,
text: "Rotated",
size: 1,
rotate: 45,
style: { fillStyle: "yellow" },
}}
layer={3}
/>;

{
/* Fixed-size label: always 14px on screen, regardless of zoom */
}
<CanvasTileEngine.Text
items={{
x: 5,
y: 3,
text: "Ankara",
fontPx: 14,
style: { fillStyle: "white" },
}}
layer={3}
/>;

{
/* Multiple texts (batch rendering) */
}
<CanvasTileEngine.Text
items={[
{ x: 0, y: 0, text: "A", size: 2, style: { fillStyle: "red" } },
{ x: 1, y: 0, text: "B", size: 2, style: { fillStyle: "blue" } },
{ x: 2, y: 0, text: "C", size: 2, style: { fillStyle: "green" } },
]}
layer={3}
/>;
Two sizing modes

size works like other draw components - it's in world units, so size: 1 text has a font em box of one tile and scales with zoom. Use fontPx instead for labels that must stay readable at any zoom level (map labels, names): the text keeps the same pixel size on screen no matter how far you zoom out.

<Image>

Draw images scaled to world units.

PropTypeDefaultDescription
itemsImageItem | ImageItem[]RequiredImage definitions.
layernumber1Rendering layer.
visibleOf(item) => boolean | undefined-Per-item show/hide: false skips the item (not painted, not hit-testable) — marker category filters without a new items array.
interactiveOf(item) => boolean | undefined-Per-item hit-test opt-out: false keeps the item painted but transparent to hit queries.

There is no styleOf — images carry no style; appearance changes go through item fields like opacity, which renderers read live at paint time.

ImageItem Properties:

PropertyTypeDescription
imgHTMLImageElementThe loaded image object.
x, ynumberWorld coordinates.
sizenumberSize in grid units (maintains aspect ratio).
sizePxnumberFixed size in screen pixels, independent of zoom — marker-style images. Wins over size. Ignored by StaticImage.
flipXbooleanMirror horizontally (a true mirror — no rotation can produce it). Combines with rotate and sprite.
flipYbooleanMirror vertically.
rotatenumberRotation angle in degrees (0 = no rotation, positive = clockwise).
spriteSpriteRectSource rectangle in sheet pixels — draws a sub-region of img. For animation, use <Sprite>.
opacitynumberOpacity from 0 (transparent) to 1 (opaque). Default 1. Ideal for ghost/preview placements.
dataTDataArbitrary app data. Never read by the engine; returned on hitTest results as hit.item.data.
function MapWithImages() {
const engine = useCanvasTileEngine();
const [treeImg, setTreeImg] = useState<HTMLImageElement | null>(null);

useEffect(() => {
if (engine.isReady && engine.images) {
engine.images.load("/assets/tree.png").then(setTreeImg);
}
}, [engine.isReady, engine.images]);

return (
<CanvasTileEngine
engine={engine}
config={config}
renderer={new RendererCanvas()}
>
{/* Single image */}
{treeImg && (
<CanvasTileEngine.Image
items={{ x: 2, y: 3, size: 1.5, img: treeImg }}
layer={2}
/>
)}

{/* Multiple images (batch rendering) */}
{treeImg && (
<CanvasTileEngine.Image
items={[
{ x: 5, y: 3, size: 1.5, img: treeImg },
{ x: 7, y: 3, size: 1.5, img: treeImg, rotate: 45 },
{ x: 9, y: 3, size: 2, img: treeImg },
]}
layer={2}
/>
)}
</CanvasTileEngine>
);
}

Advanced

<DrawFunction>

For maximum flexibility, use a custom draw function with direct rendering context access.

PropTypeDefaultDescription
children(ctx, coords, config, transform) => voidRequiredDraw function.
layernumber1Rendering layer.
<CanvasTileEngine.DrawFunction layer={4}>
{(ctx, coords, config, transform) => {
// ctx = Rendering context (type depends on renderer)
// coords = Top-left world coordinate of the view
// config = Current engine configuration
// transform = { worldToScreen, screenToWorld } coordinate helpers

// Cast to the appropriate context type for your renderer
const context = ctx as CanvasRenderingContext2D;

// Draw at a world position without doing the pixel math yourself:
const p = transform.worldToScreen(5, 3); // pixel at the center of cell (5, 3)
context.fillStyle = "purple";
context.fillRect(p.x - 5, p.y - 5, 10, 10);
}}
</CanvasTileEngine.DrawFunction>
Rule of thumb

Everything you pass to ctx is pixels. worldToScreen is for drawing (world in, pixels out); screenToWorld is for querying (pixels in, world out — feed it to Math.floor or hitTest, never back into ctx).

onDraw Callback

The onDraw prop runs after all layers are drawn but before debug overlays.

<CanvasTileEngine
engine={engine}
config={config}
renderer={new RendererCanvas()}
onDraw={(ctx, coords, config, transform) => {
// Same signature as DrawFunction children:
// coords = top-left world coordinate, config = live engine config,
// transform = { worldToScreen, screenToWorld } helpers

// Cast to the appropriate context type for your renderer
const context = ctx as CanvasRenderingContext2D;

context.strokeStyle = "red";
context.lineWidth = 5;
context.strokeRect(0, 0, config.size.width, config.size.height);
}}
>
{/* children */}
</CanvasTileEngine>

Static Caching (Pre-rendered Content)

For large static datasets (e.g., mini-maps with 100k+ items), use static components that cache content to an offscreen canvas.

When to Use Static Caching

ScenarioUse Static?Why
Mini-map with 100k items✅ YesAll items visible, static content
Main map with 100k items❌ NoOnly viewport visible, culling is enough
Overview map (fixed zoom)✅ YesStatic zoom, all items visible
Dynamic content (units moving)❌ NoContent changes frequently

<StaticRect>

Pre-renders rectangles to an offscreen canvas. Supports rotate and radius properties.

PropTypeDefaultDescription
itemsDrawObject[]RequiredRectangle definitions.
cacheKeystringRequiredUnique cache key.
layernumber1Rendering layer.
const miniMapItems = useMemo(
() =>
items.map((item) => ({
x: item.x,
y: item.y,
size: 0.9,
style: { fillStyle: item.color },
rotate: item.rotation,
radius: 0.1,
})),
[items],
);

<CanvasTileEngine.StaticRect
items={miniMapItems}
cacheKey="minimap-items"
layer={1}
/>;

<StaticCircle>

Pre-renders circles to an offscreen canvas.

<CanvasTileEngine.StaticCircle
items={markers}
cacheKey="minimap-markers"
layer={2}
/>

<StaticImage>

Pre-renders images to an offscreen canvas. Supports rotate property.

<CanvasTileEngine.StaticImage
items={terrainTiles}
cacheKey="terrain-cache"
layer={0}
/>
Automatic Cache Management

Static components automatically:

  • Clear the cache when cacheKey changes
  • Clean up the cache on unmount
  • Rebuild when items change
Memory Usage

Each static cache creates an offscreen canvas sized to fit all items. Use static caching only when the performance benefit justifies the memory cost.

Styling by State (styleOf)

Selection, hover, and highlight state should not go through items. Deriving a styled copy of the array on every state change gives each change a new array identity, which re-registers the draw callback and rebuilds the spatial index — for items whose geometry never moved. At 50k+ items that is real per-click cost.

The styleOf prop moves that styling to paint time. It runs per item on every frame; the returned fields overlay the item's own style (undefined leaves the item as-is):

function SeatMap({ seats }) {
const engine = useCanvasTileEngine();
const [selected, setSelected] = useState<ReadonlySet<string>>(new Set());

// Geometry only — does not depend on selection, so it never re-registers.
const seatRects = useMemo(
() =>
seats.map((s) => ({
x: s.x,
y: s.y,
size: 0.9,
style: { fillStyle: "green" },
data: { id: s.id },
})),
[seats],
);

return (
<CanvasTileEngine
engine={engine}
config={config}
renderer={new RendererCanvas()}
onClick={(coords) => {
const hit = engine.hitTestFirst<{ id: string }>(coords.raw);
if (hit) setSelected((prev) => toggle(prev, hit.item.data.id));
}}
>
<CanvasTileEngine.GridLines cellSize={1} />
<CanvasTileEngine.Rect
items={seatRects}
layer={1}
styleOf={(seat) => (selected.has(seat.data.id) ? { fillStyle: "blue" } : undefined)}
/>
</CanvasTileEngine>
);
}

styleOf is read through a ref inside the component, so — unlike items — its identity may change on every render at no cost. An inline arrow is fine; no useCallback needed. When the closure captures new state (the updated selected set), the component just repaints.

The two props now split cleanly:

  • items changed → geometry changed → re-register (keep it stable with useMemo/state).
  • styleOf changed → appearance changed → repaint only, zero rebuild.

What a decoration may change differs by primitive. Each rule is right for its own hit-test geometry, but the differences are easy to miss if you assume "it's all styleOf, it all behaves the same":

PrimitiveMay changeMay not changePer-item width/geometry instead
Rect / Circlefull style, lineWidth/lineWidthPx included-already allowed here
Textfull text style--
LinestrokeStyle, lineDash/lineDashPxlineWidth/lineWidthPxre-register with the new width
PathstrokeStyle, dash, fillStyle (see below)lineWidth/lineWidthPx, cornerRadius/cornerRadiusPxre-register with the new values

Why the split: a rect/circle border never feeds hit-test geometry (the hit area is the box/disc), so decorating its width is safe. A line/path hit corridor derives from the stroke width — and a path outline from its corner radius — resolved at registration time, so a paint-time change would silently desync what you see from what you can click. Path quirk from the same family: decorating an unfilled path with fillStyle paints the fill, but hit testing stays on the stroke.

Static components (<StaticRect> etc.) do not take styleOf: their cache replays a recorded image, so per-frame decoration cannot apply.

Visibility and Interactivity by State (visibleOf / interactiveOf)

The same live-read model extends to visibility and hit testing. Both props are read through refs like styleOf — inline arrows are fine, identity changes never re-register:

  • visibleOf: return false to skip an item for the frame. It is neither painted nor hit-testable, and hit queries fall through to whatever is underneath. Use it for category filters and toggles instead of deriving a filtered items array (which would re-register and rebuild the spatial index).
  • interactiveOf: return false to keep an item painted but out of hitTest/hitTestFirst/hitTestRect — the per-item version of hitTest={false}, for decorative items mixed into an interactive set or disabled entries that should stop reacting without disappearing. Not on <Text> (text never hit-tests).
const [hiddenCategories, setHiddenCategories] = useState<ReadonlySet<string>>(new Set());
const [disabled, setDisabled] = useState<ReadonlySet<string>>(new Set());

<CanvasTileEngine.Rect
items={markers}
layer={1}
visibleOf={(m) => !hiddenCategories.has(m.data.category)}
interactiveOf={(m) => !disabled.has(m.data.id)}
styleOf={(m) => (disabled.has(m.data.id) ? { fillStyle: "#9ca3af" } : undefined)}
/>;

They compose with a simple rule: visibleOf: false wins — a hidden item never hit-tests, regardless of interactiveOf. Static components take neither prop, for the same reason they don't take styleOf.

Dynamic Content

When geometry actually changes (items added, removed, or moved), React's declarative nature handles it: give items a new identity and the component re-registers the draw callback.

Performance
  • Use useMemo for computed items arrays to avoid unnecessary re-renders
  • Route selection/hover styling through styleOf — and show/hide through visibleOf — not through derived items arrays
  • For truly static content, use <StaticRect>, <StaticCircle>, or <StaticImage>
  • The engine automatically batches renders when multiple components update in the same frame