Skip to content

Rendering and cameras

Layer: 2D (goo2d)

Everything on this page is goo2d's. goo3d supplies its own equivalent, and the kernel underneath is the same either way.

How a frame happens

Rendering is two halves on two isolates, and the split is what keeps the Flutter side cheap:

flowchart LR
    subgraph G["game isolate"]
        R["<b>GameRenderer2D</b>, a GameSystem<br/>walk renderables<br/>sort by zIndex<br/>write geometry into a shared buffer"]
    end
    subgraph F["Flutter isolate"]
        V["<b>Game2D.buildView</b><br/>CustomPaint<br/>one Canvas.drawVertices"]
    end
    R -->|"shared buffer"| V

GameRenderer2D composes world transforms and writes vertex data into a shared native buffer. The Flutter side replays it as a single drawVertices call per frame — no save, restore, rotate, translate or drawImage anywhere in the replay path. The view is push-driven off tick notifications instead of polling vsync.

You declare neither half. Game2D and GameState2D bring both, which is what makes extends Game2D the whole opt-in for 2D rendering.

Making something draw

Two mixins and one declaration:

class Player extends EntityStruct
    with Transform2D, WorldTransform2D, Renderable2D {
  final sprite = Sprite.of(width: 64, height: 64, color: 0xFF4FC3F7);
}

WorldTransform2D is required — the renderer reads world transforms, not local ones.

Sprite

Everything descriptor.has takes becomes a column, so every one of these is per-entity and writable at run time:

Sprite has({
  TextureAsset? texture,
  TextureFilter filter = TextureFilter.mipmap,
  SpriteFrame frame = SpriteFrame.full,
  int color = 0xFFFFFFFF,
  double width = 0,
  double height = 0,
  int zIndex = 0,
  bool visible = true,
  RelativeOffset2D pivot = RelativeOffset2D.center,
  NineSliceBorder nineSliceBorder = NineSliceBorder.none,
})
Field Meaning
texture The image, or null for a flat colour
color ARGB. Multiplied with the texture, so it tints — and is the whole colour when untextured
width, height Size in world units
zIndex Draw order. Higher draws later, on top
visible Per-entity on/off, tested before a draw record is ever built
pivot Where the sprite's origin sits within itself
frame The sub-rectangle of the texture to draw — atlases and sprite sheets
nineSliceBorder Stretchable borders for panels and bars
filter Sampling: mipmap, and the crisp option for pixel art

The values you pass to has are defaults for every new row, not fixed values:

sprite
  ..color[entity] = 0xFFFF0000
  ..zIndex[entity] = 1000
  ..visible[entity] = false;

Hiding is a toggle, not a removal

visible[entity] = false drops the sprite before it becomes a draw record. There is no "remove the renderer component" — see Coming from Unity, Godot or Flutter.

Which way a pivot moves the sprite

A pivot's fraction is measured from the texture's top-left, so a fractionY of 0 is the top edge and 1 is the bottom. That is texture space, and texture space starts at the top in every atlas you are likely to import.

The world it draws into is y-up. The two meet in one fact worth stating plainly: moving the pivot down the texture lifts the sprite up in the world. The pivot is the point the transform origin sits on, so pushing it toward the bottom of the image leaves more of the image above the origin.

sprite.setPivot(entity, const RelativeOffset2D(fractionX: 0.5, fractionY: 1));

That anchors a character at its feet. fractionY of 1 is the bottom edge of the texture, and the sprite stands above the entity's position.

The offset you add on top runs the same way, and this is the part that decides where a collider goes. A pivot offsetY of +20 draws the sprite 20 units higher; a Collider2D offset of +20 puts a body 20 units higher. So a collider meant to cover an off-centre sprite takes the same sign — and the same number, when the pivot was nudged with an offset instead of a fraction:

sprite.setPivot(
  entity,
  const RelativeOffset2D(fractionX: 0.5, fractionY: 0.5, offsetY: 20),
);
box.offsetY[entity] = 20; // matches, and +20 is up for both

Several sprites on one entity

Renderable2D is a multi-component, so a prefab can declare more than one:

final body = Sprite.of(
  width: 64,
  height: 64,
  texture: Textures.spritesPlayer,
);
final muzzleFlash = Sprite.of(
  width: 32,
  height: 32,
  texture: Textures.uiButton,
  visible: false,
  zIndex: 10,
);

Toggle muzzleFlash.visible[entity] for a frame instead of spawning and destroying an entity.

Textures

A texture comes from the generated asset enum. Declare the asset, then hand the handle to the sprite:

class Player extends EntityStruct
    with Transform2D, WorldTransform2D, Renderable2D {
  final sprite = Sprite.of(
    width: 64,
    height: 64,
    texture: Textures.spritesPlayer,
  );
}

Declare the asset on whatever uses it. has is idempotent per identity and prefabs share their scene's descriptor, so declaring the same texture in a prefab and its scene yields the identical handle — one address, one decode. See Assets.

Atlases and sprite sheets

SpriteFrame selects a sub-rectangle of the texture, so many sprites can share one decoded image — one upload, one batch. A frame is stored as fractions of the texture, and the two named constructors do the division for you at declare time:

// A uniform sheet: 8 columns x 4 rows, cell 5 (row-major).
const walk0 = SpriteFrame.grid(columns: 8, rows: 4, index: 5);

// A packed atlas: a pixel rectangle on a sheet, divided by the sheet's size.
const buttonFace = SpriteFrame.pixels(
  x: 128, y: 64, width: 96, height: 32,
  sheetWidth: TextureSize.spritesPlayerWidth,
  sheetHeight: TextureSize.spritesPlayerHeight,
);

// The default: the whole texture.
const whole = SpriteFrame.full;

TextureSize is generated beside Textures, from the image headers, and holds one static const int pair per texture. Re-exporting the art at a new size changes those constants on the next good generate and changes nothing you wrote. Typing 512 here instead is the same number in two places, and only one of them moves.

There are two forms and they are not interchangeable:

Textures.spritesPlayer.width reads well, and is not a constant expression
TextureSize.spritesPlayerWidth usable in const, which is what the table below needs

Field access on an enum value is never constant, so the first form cannot appear in a static const List<SpriteFrame>.

Both are const, so a frame table costs nothing at run time:

class Player extends EntityStruct
    with Transform2D, WorldTransform2D, Renderable2D {
  final animTime = Field.float64();

  final sprite = Sprite.of(width: 64, height: 64);

  static const List<SpriteFrame> walkCycle = <SpriteFrame>[
    SpriteFrame.grid(columns: 8, rows: 4, index: 0),
    SpriteFrame.grid(columns: 8, rows: 4, index: 1),
    SpriteFrame.grid(columns: 8, rows: 4, index: 2),
    SpriteFrame.grid(columns: 8, rows: 4, index: 3),
  ];
}

width and height on Sprite.of are world units, not pixels, so they are not the texture's size and TextureSize does not belong there. Drawing a sprite at its native pixel size is TextureSize.spritesPlayerWidth * unitsPerPixel for whatever scale the game uses.

Then advance it per entity from a system. animTime is a Field.float64() column on the prefab, and 12 is the frame rate:

for (final group in players.groups()) {
  final player = group<Player>();
  for (final entity in group) {
    final t = player.animTime[entity] + dt;
    player.animTime[entity] = t;
    final step = (t * 12).floor() % Player.walkCycle.length;
    player.sprite.frame[entity] = Player.walkCycle[step];
  }
}

SpriteFrame.grid is exact whatever the image's pixel size — the constructor never asks how big the source is, because it does not need to know.

Text

Text2D puts a line of text in the world: a damage number over an enemy, a name above a character, a price on a sign. It is drawn from a grid font you supply, it sorts with the sprites on the same zIndex scale, and the camera moves and scales it like anything else.

class DamageNumber extends EntityStruct
    with Transform2D, WorldTransform2D, Text2D {
  late final TextureAsset atlas;

  @override
  void describeAssets(AssetDescriptor descriptor) {
    super.describeAssets(descriptor);
    atlas = descriptor.has(fontKey);
  }

  @override
  BitmapFont get textFont =>
      BitmapFont(texture: atlas, columns: 16, rows: 6, glyphCount: 95);

  @override
  void describeStruct(DataDescriptor data) {
    super.describeStruct(data);
    textCodeUnits.length = 8;
    textCellWidth.initialValue = 8;
    textCellHeight.initialValue = 12;
  }
}

Then write the text per entity:

entity<Text2D>().setText('Boss');
entity<Text2D>().setInt(-24);
final showing = entity<Text2D>().text;

setInt builds no String, so a number that changes every frame costs no allocation at all. setText allocates nothing either — whatever built the string did.

The font is a sprite sheet

A BitmapFont is a texture plus three numbers: how many cells across, how many down, and which code point cell 0 draws. A character's cell is codeUnit - firstCodepoint, split by columns, and its rectangle in the atlas is that cell divided by the grid — no pixel size anywhere, so a repacked atlas still slices correctly and nothing waits for the image to decode.

firstCodepoint is 32 by default, which is where an ASCII sheet normally starts. glyphCount says how many cells actually hold a glyph, for a last row that is only partly filled. A code unit with no cell draws nothing and still advances, so a missing character leaves its gap instead of pulling the rest of the line left.

There is no flutter: fonts: entry involved. That mechanism belongs to the Flutter isolate and the renderer runs on the other one; a bitmap font is an ordinary texture asset and goes through the same pipeline as every other image.

The engine ships no font. Supply your own PNG grid — one texture, one TextureKey, and the three numbers above.

The font belongs to the prefab, not to the row: textFont is read once per archetype, when the archetype is described, and the renderer draws from the stored answer in textFontResolved. Two fonts in one scene are two prefabs.

Capacity is storage, not a limit you can bend

textCapacity reserves that many UTF-16 code units in every row of the archetype, the same way hasPolygonCollider's maxPoints does. Pick it for the longest text that prefab will ever show.

A string that does not fit is a programming error, so a debug run stops on it. A release build has no assert to stop it: it keeps what fits and adds the rest to Text2D.textCodeUnitsDropped, a running total over every entity of that prefab. Zero there means no label has ever been cut; anything else is how much text is missing and by how much the capacity is short.

The pivot is the alignment

A label's box is as wide as the text currently in it, and the pivot resolves against that box:

textPivotFractionX Where the entity sits
0 at the label's left edge — left-aligned
0.5 (default) in its middle — centred, however the text changes length
1 at its right edge

textPivotOffsetX/textPivotOffsetY shift it by a fixed number of world units on top of that. There is no separate alignment enum; this is it.

textCellWidth and textCellHeight are one glyph's size in world units, and textLetterSpacing adds to the advance without changing the cell — negative to tighten a grid whose cells carry more side bearing than you want.

What a label costs

One candidate and one record per glyph it draws. maxSpritesPerTick counts records, so a screen of long labels reaches the cap sooner than the entity count suggests, and a label is admitted all or nothing: one that does not fit closes the budget for everything behind it. A code unit the font has no cell for is charged nothing, and an empty label is not a candidate at all.

The row stays small — textCapacity code units and about fifty more bytes, because the font, its metrics and the atlas address are on the component. The same sixteen glyphs declared as sixteen sprites would be a 2.5 KiB row, which is ten times the row size that already cost this renderer 42% of its write pass.

One label per entity, and world space only

Text2D is a plain component, so an entity that wants both a name and a damage number wants two entities. Parent one to the other and it follows.

There is no screen-space text. A HUD is Flutter widgets over the GameView — see Where your UI belongs — and that is the first thing to reach for when what you want is a score in the corner. Text2D is for text the world owns, and a prefab mixing it in beside ScreenTransform2D trips a debug assert instead of drawing its label through the camera.

Not built, and named here so you do not go looking: proportional metrics, kerning, line wrapping, and shaping for scripts that need it. \n is not a line break — it is a code unit like any other, so two lines are two entities.

Cameras

A camera is an entity: Transform2D, WorldTransform2D and Camera.

class Eye() extends EntityStruct with Transform2D, WorldTransform2D, Camera;
Column Meaning
cameraZoom Screen pixels per world unit. 1 is 1:1, 2 zooms in, 0.5 out
cameraView Which declared CameraView this camera fills, or null for none
final camera = scene.addEntity(eye);
eye.cameraView[camera] = game.defaultCamera;
eye.cameraZoom[camera] = 2;

cameraView is typed, not an integer — a stray int does not compile there.

A camera's rotation is ignored

The projection reads the camera's world x, its world y and cameraZoom, and nothing else. The same scene through a camera at rotation 0 and through one at rotation π/2 draws identically, and a camera parented to something that turns inherits the turn and still draws upright. A banking view, or one locked to a subject's facing, has nothing here to build on (#172).

Views

A view is a surface a camera can fill. Game2D declares one for you:

GameView(camera: game.defaultCamera)

Declare more when you want split screen, a minimap, or a second window:

class MyGame extends Game2D {
  late final CameraView minimap;

  @override
  void describeCameras(CameraDescriptor descriptor) {
    super.describeCameras(descriptor);
    minimap = descriptor.has();
  }
}

Each view sizes and allocates its own per-view storage, and each draws the scene its own camera is in — so two views can be looking at different scenes at the same instant.

One camera per view

Two cameras pointing at the same view trips a debug assert. A camera defines that view's origin, so a second one has no meaning. In release the first in query order wins.

There is no switch that turns a camera off. Setting its view to null is what takes one out of play.

No camera at all

A game with no active camera draws at the origin with a zoom of 1, and the whole world is drawn. A game that has not placed a camera yet shows something, not a black screen.

GameView.headless(game: game) is the other legitimate shape: a HUD-only or headless-plus-Flutter game, with no camera and nothing painted.

Screen space

ScreenTransform2D places an entity against the view instead of against the world. Mix it in beside Transform2D and the same offset columns become view units measured from an anchor on the view, the camera's zoom stops scaling it, and the camera's position stops moving it.

class Backdrop extends EntityStruct
    with Transform2D, ScreenTransform2D, Renderable2D {
  final fill = Sprite.of(width: 1, height: 1, color: 0xFF203040);

  @override
  final screenLayer = ScreenLayer.behind;

  @override
  final screenWidthAxis = ScreenAxis.fraction;

  @override
  final screenHeightAxis = ScreenAxis.fraction;
}

That draws a rectangle covering the whole viewport, behind everything the world draws, at whatever size the view happens to be.

Which mixin decides which space

mixins space
Transform2D flat world — the offsets are world units, nothing composes them
Transform2D, WorldTransform2D composed world — the offsets compose with every ancestor's
Transform2D, ScreenTransform2D screen — the offsets are view units from an anchor

ScreenTransform2D and WorldTransform2D on one prefab trips a debug assert. They mean two different things by an offset and there is no composition that reconciles them: an ancestor's offset is world units, this entity's is view units, and a parent and a child anchored to two different corners share no origin to compose through. Group screen-space art with several Sprites on one entity instead.

A screen-space entity may still be a Child — nothing composes it, so it draws where its own offsets put it and a parent that moves or turns takes it nowhere.

Where it lands

viewX = anchor.fractionX * viewWidth  + transformOffsetX
viewY = anchor.fractionY * viewHeight - transformOffsetY

ScreenAnchor names the nine corners, edges and centre of the view. +y is up here as everywhere else, which is where the second minus sign comes from, and ScreenAnchor.center with no offsets is the middle of the view.

The sprite's own pivot is a separate thing and keeps its default: anchoring to bottomRight puts the sprite's centre on the corner, so half of it is off screen. Set pivot: RelativeOffset2D.zero and the sprite's top-left corner goes on the anchor instead.

A view nothing has laid out reports a size of zero, so every anchor collapses onto its top-left corner. That is what a headless test and the first tick of a real game both see.

Sizing per axis

ScreenAxis says what a Sprite's width and height mean, one axis at a time:

  • units — a length in view units, the same number at every view size.
  • fraction — a fraction of the view's own width or height. 1 fills the view on that axis, 0.5 covers half of it.

The two axes are independent, so a banner half the view wide and a fixed twenty units tall is screenWidthAxis = fraction with width: 0.5 and the height left alone. Aspect-preserving fit is not built — see Implementation status.

Nothing per view is in the row

Two views can show one scene at two sizes in the same instant, so there is no single pixel width to store. Every ScreenTransform2D member is an overridable field on the prefab, read once per archetype per view, and the renderer turns it into pixels inside its own per-view walk where that view's size is in scope. One backdrop is correct in a 400-pixel minimap and a 1920-pixel main view at once, and the row grows by nothing.

The cost is the other half of that: an entity cannot change its anchor, layer or sizing mode at run time. Offsets, scale, rotation and every Sprite field still move; two anchors means two prefabs.

Layers, not a large zIndex

ScreenLayer.behind draws before every world sprite, ScreenLayer.front after every world sprite and every label. Within a layer, zIndex orders as usual.

Screen-space entities do not interleave with world sprites by zIndex, and the reason is the sort. zIndex is a plain int32 a game may use as a sparse key, and the renderer buckets its whole queue over the range between the smallest and largest z it sees: one HUD element at 1 << 20 pushes every sprite in the frame onto the fallback merge sort, and a "safe" 60,000 grows the bucket array to 60,001 ints that are never released. A layer costs three comparisons per queued sprite and a frame with no screen-space entity in it costs two in total.

The budget spends from the front of the scene backwards, so the front layer is admitted first: a pinned element does not vanish because twenty thousand particles were queued ahead of it. A backdrop is the first thing a frame over budget drops.

What is not here

No parallax, no texture tiling, no auto/cover/contain fit, and no screen-space text — Text2D on a ScreenTransform2D entity is refused at declare time, and not drawn through the camera. See Implementation status.

Coordinate conversion

Going between a GameView pixel and world space is a first-class operation — picking, placing UI at a world position, dragging:

final projection = getSystem<PointerPickingSystem>().projection;
final worldX = projection.viewToWorldX(localX);
final worldY = projection.viewToWorldY(localY);
final viewX  = projection.worldToViewX(entityWorldX);

CameraProjection re-resolves the active camera and the view size each tick, so it always reflects the current zoom and viewport.

The y sign flips here and nowhere else. World +Y is up and a GameView pixel's y grows downward, so these four methods are the whole of the conversion between the two. Use them rather than world - cameraOrigin by hand, or a tooltip you place at a world position ends up mirrored about the middle of the view.

A zoom of zero maps the whole world onto one pixel, so the inverse reports the camera's own origin, not an infinity that would poison every downstream comparison silently.

Debug draw

debugDraw puts shapes over the world from a system: a line for where an agent thinks it is heading, a circle for a search radius, a label for the state it is in. A steering vector is a picture and not a value, so a channel or a log cannot show it. Reach it from any GameSystem or Component, the way you reach pointerPicking.

class Navigation extends GameSystem with FixedTickable {
  @override
  void onFixedUpdate() {
    for (final group in players.groups()) {
      final world = group<WorldTransform2D>();
      for (final entity in group) {
        debugDraw.line(
          world.worldX[entity],
          world.worldY[entity],
          targetX,
          targetY,
          color: 0xFF00FFFF,
        );
        debugDraw.circle(targetX, targetY, radius: 0.3);
        debugDraw.label(world.worldX[entity], world.worldY[entity], 'seek');
      }
    }
  }
}
Call Draws Records
line(x0, y0, x1, y1) a straight segment between two world points 1
circle(x, y, radius: r) an outline, as segments chords — 24 by default one per segment
label(x, y, 'text') one line of text centred on the point one per glyph stroke, about four a character

Colours are packed ARGB integers, as they are on Sprite.color and Text2D.textColor. The default is magenta, 0xFFFF00FF, which no art asset is.

World space, pixel ink

Positions are world coordinates and go through the same CameraProjection the sprites go through, so a line lands on the thing it describes at any camera position and any zoom. thickness and a label's size are view pixels and do not scale with the camera: a one-world-unit label is four pixels tall on a zoomed-out map and tells you nothing, while a one-world-unit line is a blob at 4x.

Shapes are drawn after the whole scene, in call order. They do not sort against sprites on zIndex — the overlay is on top of everything, so a sprite cannot cover the thing you are reading.

It is compiled out of a release build

debugDrawEnabled is a const bool: true in a debug build, false in profile so a profile run measures the game and not the overlay, and false in release. Everything sits behind it. A release build reserves no debug buffer, runs no debug pass, builds no second canvas, and debugDraw resolves to an instance that stores nothing and whose methods return on their first line.

Override it either way:

flutter run --release --dart-define=goo2d.debugDraw=true
flutter test --dart-define=goo2d.debugDraw=false

The arguments are not compiled out. The call disappears; the four numbers handed to it are still computed, because they are ordinary expressions in your system. Where producing them costs something — a raycast, a path query, a string — guard the loop on the same constant:

if (debugDrawEnabled) {
  for (final entity in group) {
    debugDraw.line(0, 0, 10, 4);
  }
}

debugDrawEnabled is public for exactly that.

Its own budget

Debug shapes cross to the render isolate on a buffer of their own and never spend maxSpritesPerTick. A debug line cannot push a sprite out of a frame, so the overlay never changes the picture it is describing. The cap is maxDebugRecordsPerTick — see Budgets.

Every call is flattened to straight segments as it is made, and one segment is one record. Past the cap a call stores nothing, and DebugDraw2D.droppedSegments counts what was lost — a dropped shape looks like a system that never drew, so the count is what tells the two apart.

A label needs no font

debugDraw.label draws from a stroke alphabet built into the engine — printable ASCII, with lower case drawn as upper case. There is no TextureAsset to declare, no atlas to pack and nothing to decode, so an overlay works on the first frame of a project that has declared no font at all.

Text2D is the other kind of text: the game's own, with its grid and its atlas, sorted and scaled with the sprites around it. Reach for this one only to read a number while the game runs.

Shapes stay until something draws again

The store is emptied by the first call after a frame drew it. A system drawing on a fixed tick slower than the display keeps its shapes on screen between fixed ticks instead of flashing at the beat frequency, a paused game keeps showing what it drew last, and several fixed steps inside one displayed frame accumulate. debugDraw.clear() empties it, for an overlay that draws only while some condition holds.

Categories are a bit each, tested at the call, so two systems drawing into one overlay can be read one at a time:

debugDraw.categories = ~(1 << 3);           // everything except category 3
debugDraw.line(0, 0, 10, 0, category: 3);   // stored nothing, cost one test

The mask lives on the game isolate. Toggling it from a widget is a command, like any other write.

Budgets

class MyGame extends Game2D {
  @override
  int get maxSpritesPerTick => 24000;        // default 16384

  @override
  int get maxDebugRecordsPerTick => 8192;    // default 4096
}

This sizes the native frame buffer — the default reserves 3.56 MiB per camera view. Hitting the cap truncates the batch, which looks exactly like the renderer getting slower unless you can see the count — so the renderer exposes how many sprites it actually emitted, and lastRecordsOverBudget beside it: how many records the frame asked for and had to turn away. A debug overlay showing both is worth building early, and the second is the number to add to maxSpritesPerTick.

The cap counts records, not sprites — a sliced sprite spends one per cell it draws, nine for a full frame — so a screen of panels reaches it sooner than the entity count suggests. Only the cells that survive are charged: a capsule button sliced left and right has no top or bottom row to draw, so it costs three and not nine.

A label spends one record per glyph it draws, so text is counted the same way: one candidate, as many records as it has characters the font has cells for.

Debug draw has a second budget, and the two never meet. maxDebugRecordsPerTick is an overridable getter on the same Game, defaulting to 4096 and reserving 304 KiB per camera view in a debug build and nothing in a release one. Debug shapes cross on a buffer of their own, so a debugDraw.line cannot take a record from a sprite and cannot push one out of a frame — an overlay that changed the picture it was describing would be worse than no overlay. It is also the store's capacity, so a shape that is accepted has a record that fits. DebugDraw2D.droppedSegments counts what went past it. See Debug draw.

What goes when it runs out

The furthest layers. The renderer sorts by depth first and then spends the budget from the camera backwards, so a frame that cannot fit keeps everything in front of some depth and draws nothing behind it. The survivors are a contiguous slab: a sprite behind a refused one is refused too, even when it would have fit, because drawing a background tile while the mid-layer tile over it is missing is a worse frame than a missing back layer.

A sliced sprite is still all or nothing — either every cell it draws fits or none of them do.

Being over budget is something to fix, not a layer to lose deliberately. The policy exists so that a frame that goes over degrades in a way you can predict, not so you can plan around it.

The renderer also reports its three phases — walking renderables into the draw queue, sorting by z, and writing geometry — separately instead of as one presentation total, because they are three unrelated costs with three unrelated fixes and one number cannot direct any of them. See Performance.

In-game UI, and when to avoid it

The engine can draw buttons and panels — NineSliceBorder stretches borders for exactly that, and PointerReceiver below gives an entity pointer events.

Prefer Flutter widgets anyway. Your HUD, menus and inventories should be ordinary widgets over the GameView; make a UI element an entity only when it is as interactive as the game itself — pinned to a world position, occluded by the world, or hit tested in world coordinates. See Where your UI belongs.

The exception is art that has to sit between world sprites by depth. The painter replays the whole batch in one call, so a widget is above or below the entire GameView and never inside it. A backdrop behind the world and a layer over it are both screen space; anything with text, layout or accessibility in it is a widget.

Pointer picking

PointerReceiver gives an entity press and release events, HoverReceiver gives it enter, hover and exit, and PointerPickingSystem resolves both against its colliders. It matches either receiver with Collider2D and WorldTransform2D together, and tests the pointer against every enabled body in the entity's own local space — so a rotated, scaled entity hit-tests as the shape you can see:

class Button extends EntityStruct
    with
        Transform2D,
        WorldTransform2D,
        Renderable2D,
        Collider2D,
        PointerReceiver,
        HoverReceiver {
  final sprite = Sprite.of(width: 64, height: 64);
  final hitArea = ColliderBody.circle(radius: 32);

  @override
  void onPointerDown(PointerPickEvent event) { }
  @override
  void onPointerUp(PointerPickEvent event) { }
  @override
  void onHoverEnter(PointerPickEvent event) { }
  @override
  void onHover(PointerPickEvent event) { }
  @override
  void onHoverExit(PointerPickEvent event) { }
}

Two mixins, because a finger cannot hover

A contact is a press. A finger is either down on an entity or absent from it, with no state in between to enter or leave, so the pressing pair and the hovering trio are separate mixins. A game built for touch applies PointerReceiver alone and gets a surface where every callback fires; the name HoverReceiver says what it needs before you mix it in.

Apply both for a prefab a mouse should highlight and a finger should tap. The two are queried separately, so an entity carrying only HoverReceiver does not swallow a press aimed at what is beneath it, and one carrying only PointerReceiver does not block a highlight.

Presses come from contacts, one dispatch per contact per tick, each projected through the view that contact landed in — so two fingers on two entities both fire on the same tick, and two fingers on two GameViews project through two cameras. A mouse button produces both a contact and a button bit, and the picker takes the bit, so one click fires one onPointerDown. Which button that is stays rebindable through PointerPickingSystem.click.

A contact taken away without a lift — a call arrives, a widget wins the gesture arena — still fires onPointerUp, with PointerPickEvent.cancelled set. A handler that commits an action on release reads that and abandons instead.

Hover belongs to the cursor, and one entity hovers at a time. Enter, hover and exit are separate phases, so hover feedback does not have to be reconstructed from raw positions.

A receiver with no collider is never picked, and nothing says so — it fails the query, so it is not a candidate at all. No assert stands in for it, because Renderable2D's bounds are the obvious fallback and the wrong one: a sprite is a rectangle even when what it draws is a coin, and clicking the corner of a coin should miss. The button above carries both, and they disagree: the cursor 42 units out from the origin is inside the 64×64 sprite, outside the radius-32 circle, and picks nothing.

Each candidate gets a cheap reject before the exact test: a circle about the entity's origin, wide enough to reach the far side of every body it declared. Measured from the origin and not from each body's own offset, because the origin is the point rotation turns about — a bound measured from anywhere else swings as the entity spins, and one that comes out too small drops a click the player aimed correctly. A hit zone hung far off the origin therefore costs you a looser reject, never a wrong answer.

Picking is scoped the way drawing is: the pointer hits only entities in the scene the view's camera is in. A second scene resident behind the one on screen simulates, but nothing in it can be clicked.


Next

Input →