Skip to content

Sprites & Rendering

The Sprite is the workhorse 2D renderer: an entity with a Sprite component draws a textured quad in the world. For solid primitives without a texture there’s ShapeRenderer. Everything is drawn through a Camera, and draw order is controlled by sorting layers.

A sprite needs a Transform (where it is) and a Sprite (what it draws). Load a texture through the Assets resource and put its handle on the sprite:

import { defineSystem, Commands, Res, Query, Mut, Transform, Sprite, Assets } from 'esengine';
const spawnPlayer = defineSystem([Commands(), Res(Assets)], async (cmds, assets) => {
const tex = await assets.loadTexture('assets/textures/player.png'); // { handle, width, height }
cmds.spawn()
.insert(Transform, { position: { x: 0, y: 0, z: 0 } })
.insert(Sprite, { texture: tex.handle, size: { x: tex.width, y: tex.height } });
});

sprite.texture is a texture handle, not a path — you get one from a loader (assets.loadTexture(ref)). In the editor you don’t write this: drag an image from the Content Browser onto an entity, or set the Sprite’s texture field in Details.

Field Default Description
texture 0 Texture handle from a loader (0 = untextured, draws a white quad).
color {1,1,1,1} Tint multiplied into the texture (white = unchanged).
size {100,100} Rendered size in world units.
pivot {0.5,0.5} Anchor point the sprite rotates and scales about, as a fraction of its size. Values outside 0–1 sit off the sprite.
layer 0 Sorting layer — controls draw order (see below).
flipX / flipY false Mirror horizontally / vertically.
material 0 A custom material handle (0 = the default sprite shader).
lit false Receive 2D lights with a flat normal — the one-flag path to lit sprites (a custom material overrides it).
uvOffset / uvScale {0,0} / {1,1} Sub-rectangle of the texture to show (for atlases / scrolling).
tileSize / tileSpacing {0,0} Repeat the texture across the sprite (0 = no tiling).
parallax {1,1} Parallax scroll factor (see below).
enabled true Hide without removing the component.

color is an RGBA multiplier over the texture. White ({1,1,1,1}) draws it unchanged; tint it, or fade it by lowering alpha:

sprite.color = { r: 1, g: 0.4, b: 0.4, a: 1 }; // reddened
sprite.color = { r: 1, g: 1, b: 1, a: 0.5 }; // 50% transparent

color (and size) are animatable — key them in the Sequencer for flashes and fades.

size is the quad’s size in world units, independent of the texture’s pixel dimensions — set it to { tex.width, tex.height } for 1:1, or anything else to scale. Scale on the Transform multiplies on top of it. pivot is the normalized anchor (0–1) the sprite rotates and scales about: {0.5, 0.5} is centered, {0, 0} is the bottom-left corner, {0.5, 0} pins the bottom-center (handy for characters standing on the ground).

The same sprite rotated about three different pivots — the pivot dot stays fixed while the quad swings around it: center spins in place, a corner swings out, bottom-center pins the feet

A pivot outside 0–1 is legal and useful: {0.5, -1} puts the turning point a full sprite-height below the art, which is how you hang a swinging arm or a door off a hinge it doesn’t contain.

Storing a fraction is what lets a pivot survive a resize, but it isn’t how anyone thinks about one — see In the editor for entering it in pixels, picking it from the nine common positions, or dragging it on the sprite itself.

World units are design pixels. Dropping a texture into the viewport spawns a sprite at the texture’s pixel dimensions (size = { tex.width, tex.height }), and the editor’s cameras are authored so one world unit is one design pixel (orthoSize = designHeight / 2). So Sprite.size is in design pixels, and pivot is a normalized fraction of that size. The world is Y-up — positive Y is up on screen — which is why pivot: {0, 0} is the bottom-left corner, not the top-left.

When sprites overlap, draw order decides what’s on top. Order is set by the sorting layer (sprite.layer) — a named layer list you define in Project Settings → Rendering, so the inspector shows a dropdown of Background, Default, Foreground, etc. instead of a raw number. Lower layers draw first (behind).

Left: three sorting layers stacked so a higher layer draws in front. Right: on a Y-sorted layer a sprite lower on screen draws in front of one higher up.

Naming is a readability feature, not a limit: name up to thirty-two slots (a slot’s index is its z-order). The names are suggestions — type a number the list doesn’t offer and the field takes it, and with no names at all layer is a plain number field. The renderer sorts on any integer either way.

sprite.layer = 0; // e.g. "Background"
sprite.layer = 2; // e.g. "Foreground" — drawn on top

sprite.order is where a sprite sits inside its sorting layer: higher draws on top, the range is −128…127, and 0 says nothing at all. It outranks whatever the layer would otherwise have inferred — z on a painter layer, world Y on a Y-sorted one:

shadow.order = -1; // under its owner, wherever the owner walks
weapon.order = 1; // over the hand holding it

On a Y-sorted layer this is the only override there is, because z never reaches that key at all. Without it, keeping a shadow under its owner meant moving the sprite in Y and lying about where it stands.

An order is a promise about one sprite against everything else in the layer, and that stops scaling as soon as two assembled characters overlap: raising a weapon above the other character raises it above its own body too.

SortingGroup makes an entity and its whole subtree sort as one unit. The group states the layer and order the assembly presents outward; each member’s own sprite.order becomes its place inside the group, and nothing outside the group can land between two members.

world.insert(character, SortingGroup, { layer: 2, order: 5 });
// Inside the character, these are now free to mean only "which part is in front".
world.update(body, Sprite, (s) => { s.order = 0; });
world.update(arm, Sprite, (s) => { s.order = 1; });

Two characters are then ordered against each other by their groups alone, and neither one’s parts can interleave with the other’s.

Groups nest. A weapon prefab that carries its own SortingGroup, rigged under an arm, stays one block wherever the arm goes — the outer group still owns the layer, and the block does not come apart around a sibling of the arm.

A SpriteMask turns an entity’s own sprite into a stencil: sprites drawn after it that ask to be masked are cut by its shape. Nothing else about the sprite changes — the texture, size, pivot, flips and 9-slice are the ones you already authored — and while it is masking, the mask’s sprite is not drawn. It is the cut, not a picture.

world.insert(hole, SpriteMask, { alphaCutoff: 0.5 });
world.update(fog, Sprite, (s) => {
s.maskInteraction = SpriteMaskInteraction.VisibleOutside; // a hole in the fog
});

Masking is opt-in per sprite (Sprite.maskInteraction), so adding a mask to a scene can never make an unrelated sprite vanish:

maskInteraction What survives
None (default) The mask is ignored.
VisibleInside Only the part over the mask — a window.
VisibleOutside Only the part away from it — a hole.

alphaCutoff above 0 cuts to the sprite’s shape rather than its box: fragments below that alpha do not mask, so a round mask cuts a circle.

A mask reaches forward — it cuts what is drawn after it, in the order you already arrange by layer and order. That is the order its stencil is written in, so a sprite at the same layer and order as the mask is not cut at all. Turn on limitRange to stop the reach at a stated layer and order.

A mask cuts the picture, not the scene: picking, physics and queries do not see it.

Same layer, same z? Then scene order decides: the engine draws entities in the order the scene lists them, so the one further down the Outliner lands on top. Dragging a row in the Outliner therefore changes what covers what — and the viewport updates as you drop it, exactly as the running game will draw it. A dragged parent takes its children along, so a child always keeps its position relative to its parent.

At runtime the same order is available without touching layers — useful for “bring this card to the front”:

// Draw `card` last (on top) among these three, leaving everything else alone.
world.applyEntityOrder([other, another, card]);

For top-down games, check a layer under Project Settings → Rendering → Y-sorted layers and every sprite, shape, and text on that layer draws in world-Y order: entities lower on screen draw on top, so a character walking below a tree appears in front of it — no manual layer or z juggling.

Check a layer under Project Settings → Rendering → Depth-sorted layers and it stops using paint order entirely: what covers what is decided by the depth buffer, from each entity’s Transform z. With a perspective camera that is 2.5D — sprites at different depths occlude each other correctly from any angle, with no sort order to maintain by hand.

Which sprites take part is decided by their material’s blend mode, because that is what says whether a draw reads what is already on screen:

Blend mode In a depth layer
None (opaque) Writes depth and is occluded by anything nearer, whatever order it was drawn in.
Anything else (translucent) Tests against depth but never writes it, and stays in back-to-front paint order.

That split is not a setting — a translucent sprite that wrote depth would clip the sprites behind it into hard black edges. So a sprite you want to occlude properly needs an opaque material; leave the blend mode alone and it behaves as it always has, just with things in front of it hiding it.

To look at any of this while authoring, switch the viewport’s 2D / 3D button (scene toolbar) — the editor’s own eye becomes a perspective one. It changes only your view: the Game view always shows the scene’s camera. Picking and dragging follow, so a sprite at z = -400 is grabbable where it is drawn.

  • Flip a sprite with flipX / flipY — e.g. face a character left or right without a separate texture.
  • Atlases: show one cell of a packed sheet by setting uvOffset and uvScale to the cell’s normalized rectangle. (For frame-by-frame animation, drive these from the Animator instead of by hand.)
  • Scrolling: animate uvOffset over time to scroll a texture (water, conveyor belts), and set tileSize to repeat it across a large sprite.

How a texture is sampled is set per texture handle, not per sprite:

The same 16×16 texture magnified: Nearest keeps hard pixel edges, Linear blends them into a smooth gradient

The same tiny texture magnified ~15×. Nearest keeps texel edges hard (pixel art); Linear interpolates between texels for a smooth look (the default).

Enum Values Meaning
TextureFilter Nearest, Linear Nearest = hard texel edges (pixel art); Linear = smooth interpolation (default).
TextureWrap Repeat, ClampToEdge, MirroredRepeat What UVs outside 0–1 sample — tiling repeats, clamp stretches the edge pixel.
import { TextureFilter, TextureWrap, setTextureFilter, setTextureWrap, setTextureParams } from 'esengine';
setTextureFilter(tex.handle, TextureFilter.Nearest); // crisp pixel art
setTextureWrap(tex.handle, TextureWrap.Repeat); // tile outside 0–1 UVs
// The two helpers above each reset the *other* params to their defaults
// (ClampToEdge / Linear). To set filter and wrap together, use the full form:
setTextureParams(tex.handle, TextureFilter.Nearest, TextureFilter.Nearest,
TextureWrap.Repeat, TextureWrap.Repeat);

setTextureParams(textureId, minFilter, magFilter, wrapS, wrapT) is the complete surface — separate minification/magnification filters and per-axis wrap. Repeat is what makes tileSize tiling and UV-scroll effects seamless.

How each wrap mode samples UVs outside 0–1: Repeat tiles the texture, Clamp to edge extends the border pixel, Mirror flips every other tile

What each wrap mode does with UVs outside the 0–1 range (centre tile = the texture): Repeat tiles it, Clamp to edge stretches the border pixel, and Mirror flips every other copy.

parallax scales how much a sprite moves relative to the camera: 1 moves with the world, 0 locks it to the camera (a fixed backdrop), and values in between scroll slower for depth. Layer a few sprites at 0.2, 0.5, 0.8 for a classic parallax background.

Assign sprite.material to draw the sprite with your own shader — a tint, a dissolve, a distortion — see Materials & Shaders.

To make a sprite respond to Light lights, the simplest path is to set sprite.lit = true — no custom material needed (it’s lit with a flat normal). For normal maps or a fully custom look, use a Lit-2D material instead. Either way, see 2D Lighting & Shadows.

Sprite filters (outline, glow, drop shadow)

Section titled “Sprite filters (outline, glow, drop shadow)”

For the most common per-sprite effects you don’t need to write a shader — SpriteFilter builds a ready-made material you assign to sprite.material:

import { SpriteFilter } from 'esengine';
sprite.material = SpriteFilter.createOutline({ color: { r: 1, g: 1, b: 1, a: 1 }, width: 2 });
sprite.material = SpriteFilter.createGlow(); // warm outline preset
sprite.material = SpriteFilter.createDropShadow({ offsetX: 3, offsetY: 3, blur: 2 });

Tweak a live filter with setOutlineColor / setOutlineWidth / setShadowOffset / setShadowBlur (selection pulses, hit flashes). Each call creates a material — share one handle across sprites that use the same look, and pass texelSize: { x: 1/texWidth, y: 1/texHeight } for exact pixel widths on textures that aren’t 512×512.

For solid circles, capsules, and rounded rectangles — placeholders, bars, debug overlays — use ShapeRenderer instead of a texture (ShapeType.Circle, Capsule, or RoundedRect):

import { ShapeRenderer, ShapeType } from 'esengine';
cmds.spawn()
.insert(Transform, { position: { x: 0, y: 0, z: 0 } })
.insert(ShapeRenderer, {
shapeType: ShapeType.RoundedRect,
size: { x: 200, y: 80 },
cornerRadius: 12,
color: { r: 0.2, g: 0.6, b: 1, a: 1 },
});

ShapeRenderer shares the sorting layer and color conventions with Sprite.

TrailRenderer draws a world-space ribbon along an entity’s recent path — sword swipes, dashes, projectile streaks. Add the component and move the entity; the engine records the position history and renders the taper for you:

cmds.spawn()
.insert(Transform, { position: { x: 0, y: 0, z: 0 } })
.insert(TrailRenderer, { time: 0.4, startWidth: 24, blendMode: 1 }); // additive glow
Field Default Description
time 0.5 Seconds each trail point lives before fading out of the tail.
minVertexDistance 5 Min world distance moved before a new point is recorded (0 = every frame).
emitting true false freezes emission — the streak detaches and fades in place.
startWidth / endWidth 20 / 0 Ribbon width at the head / tail (0 tapers to a line).
startColor / endColor white / white α 0 Color lerped head → tail; tail alpha 0 is the fade-out.
texture 0 Texture along the ribbon (U runs head→tail); 0 = vertex colors only.
blendMode 0 0 Normal, 1 Additive (glow), 2 Multiply.
layer / material 0 / 0 The usual sorting layer / custom material.

The Trail resource (Res(Trail)) adds clear(entity) to drop an entity’s history instantly — e.g. when teleporting, so the trail doesn’t streak across the map.

BitmapText renders world-space text from a prebaked bitmap font — damage numbers, name tags, retro UI. Point font at a BMFont asset (.fnt text or .bmfont JSON) and set text:

Field Default Description
text '' The string to draw.
font 0 The bitmap-font asset (.fnt / .bmfont).
fontSize 1 Scale over the font’s authored size.
align Left Left / Center / Right.
spacing 0 Extra per-character spacing.
color {1,1,1,1} Tint — animatable (flash a damage number).
layer / parallax 0 / {1,1} Same sorting layer / parallax semantics as Sprite.

For screen-space UI text (layout, wrapping, SDF sharpness), use the UI Text widget instead — see UI.

“Did the click land on this sprite?” is two steps: convert the screen point to world space, then test it against the sprite’s rectangle. The CameraView resource does the first (see Camera); for the second the SDK exports pivot-aware point tests:

Function Tests
pointInWorldRect(px, py, worldX, worldY, worldW, worldH, pivotX, pivotY) An axis-aligned rectangle placed by position + pivot (a sprite’s exact footprint when unrotated).
pointInOBB(…, rotationZ, rotationW) The same rectangle rotated — pass the transform quaternion’s z/w; falls back to the rect test when unrotated.
pointInHitArea(px, py, area) A custom HitAreaShape — rect, circle, or polygon.
import { defineSystem, Query, Res, CameraView, Input, Transform, Sprite, pointInOBB } from 'esengine';
const clickSprites = defineSystem(
[Query(Transform, Sprite), Res(CameraView), Res(Input)],
(q, view, input) => {
if (!input.isMouseButtonPressed(0)) return;
const p = view.screenToWorld(input.mouseX, input.mouseY);
if (!p) return;
for (const [entity, t, s] of q) {
const w = s.size.x * t.worldScale.x, h = s.size.y * t.worldScale.y;
if (pointInOBB(p.x, p.y, t.worldPosition.x, t.worldPosition.y,
w, h, s.pivot.x, s.pivot.y,
t.worldRotation.z, t.worldRotation.w)) {
console.log('hit', entity);
}
}
});

For sprites whose clickable region isn’t their rectangle — an irregular character, a donut-shaped button — describe the region as a HitAreaShape in sprite-local coordinates and test the localized point:

import { pointInHitArea, type HitAreaShape } from 'esengine';
const hull: HitAreaShape = { type: 'polygon', points: [0, 0, 64, 0, 64, 48, 32, 64, 0, 48] };
// For an unrotated sprite, localize by subtracting its origin:
const hit = pointInHitArea(p.x - t.worldPosition.x, p.y - t.worldPosition.y, hull);

screenToWorld is also exported standalone (it takes an inverse view-projection matrix and a viewport rectangle) for code that manages its own camera math — the CameraView resource is the same function with the active camera filled in. For interactive UI, don’t hand-roll any of this: Interactable + UIEvents do picking for you (see UI).

  • Create → Sprite adds a sprite entity, or drag an image from the Content Browser into the viewport to spawn one already textured.
  • Every field above is editable in Details; sprites, cameras, and lights show as gizmos in the viewport. See The Editor.
  • pivot is authored three ways, all writing the same stored fraction:
    • The 3×3 grid under the field picks the nine common positions in one click.
    • The frac / px switch on the row enters it in pixels instead — px shows pivot × size, and typing 32 stores 32 / size. The unit is a view; the scene always holds the fraction.
    • With the pointer tool, a single selected sprite gets a pivot dot in the viewport. Dragging it moves the pivot through the artwork without moving the artwork — the transform takes up the slack — so you can put the turning point on a foot or a hinge by eye. It hides under Move/Rotate/Scale, whose centre grab sits in the same place.
  • Textures — the image itself: formats, import settings, and how to read its width and height.
  • Animation — sprite-sheet flipbooks and tweened properties.
  • Custom Drawing — meshes and immediate-mode shapes beyond sprites.
  • 2D Lighting & Shadows — normal maps and per-sprite lighting.