Docs / Building a world · v0.95.0

Making a 2D game

Making a 2D game

Floptle is a 3D engine. A 2D game is a 3D scene you refuse to use the third axis in: everything sits at z = 0 and the camera looks straight down -Z. There is no 2D mode to switch on, and you don't need one.

What you do need is three node types, because building a 2D game out of ordinary scene nodes runs into the same walls every time: tile seams, no per-sprite tint, a room that costs two hundred nodes — and, for a single sprite, a plane whose size is not the size you asked for and an origin that cannot be put at a character's feet.

Building a level by hand? This page is the model — what the node types are and how a script drives them. For the ◫ Tiles tab (painting, palettes, autotiling, per-tile collision and tags) read tilemaps.md, which is the whole authoring suite.

▩ Tilemap — a level as one mesh

Add one from Hierarchy ▸ ✚ New ▸ ▦ 2D ▸ ▩ Tilemap, switch an existing node to one with Inspector ▸ Add Component ▸ Type ▸ ▩ Tilemap, or make any node into one from a script. It holds a grid of cell indices; the sheet comes from the node's own Material, so a tilemap is dressed exactly like every other surface.

lua
function start(node)
  -- The sheet: an ordinary Material with a grid on it.
  node:setMaterial{ texture = "textures/tiles.png", sheetCols = 8, sheetRows = 8,
                    filter = "pixelated", unlit = true }

  -- The grid. `tile` is the world size of one square.
  node:setTilemap{ cols = 20, rows = 12, tile = 1.5 }

  local tm = node:tilemap()
  tm:fill(0)                      -- floor everywhere
  for x = 0, 19 do
    tm:set(x, 0, 4)               -- a wall along the top
    tm:set(x, 11, 4)
  end
  tm:set(9, 6, -1)                -- a hole
end

tm:set(x, y, cell) is 0-based from the top-left, matching the order the data is stored in. Outside the grid is a no-op, not a wrap — a loop that runs one past the edge should read as absent rather than quietly paint the far side. tm:get, tm:at, tm:fill, tm:fillRect, tm:size, tm:tileSize and tm:resize round out the grid; tm:cellAt / tm:worldAt convert between world space and squares through the node's own transform; and tm:solid, tm:tags, tm:hasTag and tm:autotile read the node's tileset. See tilemaps.md §7.

A square also carries an orientation — four rotations, each optionally mirrored — in bits above the cell index:

lua
tm:set(4, 2, 7, { rot = 90 })              -- a quarter-turn clockwise
tm:set(5, 2, 7, { flipX = true })          -- mirrored left-to-right
local cell, rot, flipX = tm:at(4, 2)       -- read the whole answer back

Leaving a square empty

Pass -1. Any negative cell means "no tile here", which is the convention in Tiled, Godot's TileMap and LDtk, so the obvious guess is the right answer:

lua
tm:set(gx, gy, inside and -1 or wallTile)

nil means the same thing, as does the EMPTY_TILE global and tm.EMPTY on the handle — all four are one value. A cell that is neither a tile index nor an empty marker is an error naming the value it got and the range it accepts, rather than a nearby tile.

That is worth stating because it used to be the reverse: the only empty value was a Rust constant Lua could not name, and -1 raised. Tilemaps get built inside createNode callbacks, so the raise took the rest of the callback with it — a game shipped an arena whose walls were two rows tall and whose node was never positioned, and the report that came back was "the walls are not visible".

Why it is one mesh, and why that matters

The reason this is a node type rather than advice to place quads is a bug you will otherwise ship: hairline gaps between tiles that open and close as the camera moves.

Give every tile its own transform and tile i's right edge is computed as (i + 0.5) * tile + half, while tile i + 1's left edge is (i + 1.5) * tile - half. Those are different float expressions for the same number. They disagree in the last bit, they land either side of a pixel boundary independently, and a line of background shows through.

The usual mitigations — snapping the camera to whole pixels, overlapping the tiles by a few percent — hide it rather than fix it, and the overlap only works while your tiles happen to be opaque right to the edge.

A tilemap puts every tile in one vertex buffer, where a shared edge is written once and is therefore bit-identical on both sides. Two triangles that share an exact edge are watertight under the rasterizer's fill rule: there is no gap, at any zoom, from any camera position. It is also one draw call instead of two hundred nodes.

▫ Sprite — one of them

Add one from Hierarchy ▸ ✚ New ▸ ▦ 2D ▸ ▫ Sprite, or switch an existing node to one with Inspector ▸ Add Component ▸ Type ▸ ▫ Sprite. It is a flat quad wearing a cell of its Material's sheet — a sprite batch of one, named.

You could always build this out of a Plane and a Material, and every 2D project did, each of them re-deriving the same three facts. A node type answers them once:

lua
node:setMaterial{ texture = "art/hero.png", sheetCols = 4, sheetRows = 4,
                  filter = "pixelated", unlit = true }
node:setSprite{ ppu = 32, cell = 3, pivotY = 0 }

Size in pixels

ppu is pixels per world unit, measured against one cell — a 128×128 sheet cut 4×4 has 32-pixel cells, so ppu = 32 makes each sprite one unit across. That is the number a pixel artist already has; world units are a number they would have to work out. Re-slicing a sheet finer does not resize every sprite on it, because the measurement was never of the whole image.

Set ppu = 0 to use size — a plain world edge length — instead. That is the escape hatch for art that is not pixel art.

The Plane primitive is 1.4 units across at scale 1, not one (see PRIMITIVE_HALF). Every project that built a sprite out of one shipped sprites 40% too big until somebody measured them. A Sprite's size is the world edge, full stop.

Flip

flipX / flipY mirror the picture. Deliberately not a negative node scale, which would also mirror the node's children and invert its normals — "face the other way" should do neither.

From a script, take the sprite and assign it:

lua
function update(node, dt)
  local sp = node:sprite()
  local mx, my = input.axis2("Move")
  if mx ~= 0 then sp.flipX = mx < 0 end   -- only turn when actually moving
end

node:sprite() is this node's Sprite component as a handle: flipX, flipY, cell, ppu, size, pivotX and pivotY, each one readable and assignable. Reads answer with what you last assigned, so if sp.flipX then on the next line agrees with the line above it, and the write reaches the component the renderer draws from — the Inspector moves with it.

node:sprite() and node:sprites() are different calls. Singular is this node's own sprite, the one thing it draws. Plural is the batch handle for a node that draws many. Each one's error message names the other.

node:setSprite{ flipX = true } still works and sets several fields at once; the handle is the shorter way to write one, and the only way to read one back. Note the keys: option tables are read by name, so setSprite{ 8, 1, true } is not a positional call — it is refused, rather than setting nothing at all.

Pivot, and why it ships with Y-sorting

pivotX / pivotY put the node's origin somewhere in the sprite, 0..1 from the bottom-left. 0.5, 0.5 is the centre and is the default.

pivotY = 0 puts it at the feet, and a Y-sorted character wants that. Y-sorting reads the node's own Y, so a centred origin sorts the character by a point floating at its waist: walk up to a table and the character is sorted as though standing half a body higher than it is standing, and slides behind things it is plainly in front of. Move the origin to the feet and the sort reads where the character actually is.

The Inspector has a feet button for exactly this.

The pivot moves the picture, never the node — the origin stays where you put it, which is the whole point of moving the pivot instead.

Sprite animation — a frame names its own art

Animating a sprite was always possible: point a material at a sheet and animate its cell. It confined a clip to one sheet forever, and the sheet and the cell were separate lanes, so getting them out of step gave you a cell index read against the wrong grid — which draws a slice of the wrong picture and never says anything.

A sprite animation fixes both at once, because a frame names its own art:

ron
// art/hero_walk.spriteanim.ron
(
  fps: 12,
  loop: true,
  cols: 8, rows: 4,                                 // the sheet these frames cut
  texture: "art/hero.png",
  frames: [
    (cell: 0),
    (cell: 1),
    (texture: "art/hero_extra.png", cols: 4, rows: 4, cell: 9),  // another sheet
    (texture: "art/hero_shout.png", cols: 1, rows: 1),           // a whole image
    (cell: 2, hold: 3.0),                                        // three frames long
  ],
)
  • A frame is a reference, not an index. It carries the image, how that image is cut, and which piece. Frames may come from different sheets, and from plain PNGs that were never on a sheet — nothing has to be re-packed to be animated, and adding a frame from a second sheet is adding a line.
  • cols / rows on a frame are optional and inherit the clip's when omitted, so the ordinary one-sheet clip is one line per frame. 0 means inherit; 1 × 1 means the whole image.
  • hold is how many frame-slots one frame occupies. A hand animator's timing is not a constant frame rate, and the alternative — repeating a frame four times — makes the list unreadable exactly where the timing is the interesting part.
  • The clip ends at the end of its last frame, not at its start. A four-frame clip at 12 fps lasts a third of a second and a loop keeps all four.

fps defaults to 12, loop to true, and the clip's cols/rows to 1 — so the shortest possible clip is a list of whole images.

A sheet with no tags imports as one clip named after the file, rather than being refused: a single exported loop is the common case, and making it the case that needs a workaround would be backwards.

Making one

Right-click a sliced texture in Assets ▸ ▦ New sprite animation. It writes a .spriteanim.ron beside the image with one frame per cell, in reading order, at 12 fps. That is a starting point to cut down — a sheet is rarely one clip end to end — and it is already right about the two things that are tedious to get right by hand: the path, and which cell is which.

The entry is greyed out on an unsliced texture rather than hidden, because the reason it is unavailable is a thing you can fix: set the sheet's cols/rows in the texture's import settings first.

From Aseprite

Export the sprite sheet with its JSON (Aseprite's Export Sprite Sheet writes both), then right-click that .json in Assets ▸ ▦ Import Aseprite sheet. You get:

  • one .spriteanim.ron per tag, named after the tag — the tags are the animations, and re-entering which frames are which by eye against a picture is the step where a pixel artist decides the engine is not worth it;
  • the sheet's grid onto the .png's import settings, so the image slices the same way the clips assume. This is the half that is easy to miss: clips whose frames are right draw nothing sensible beside a texture that is not sliced, and nothing about the import would tell you there was a step left.
  • per-frame timing preserved. Aseprite times frames individually in milliseconds; the import takes the shortest frame as the clip's frame rate and turns everything longer into a hold, so every frame keeps its real duration instead of being averaged into a rate that matches none of them.

Export on a grid: By Rows or By Columns, no padding, no trimming. A packed or trimmed sheet is refused with a sentence naming the checkbox, rather than imported as a grid it is not — which would draw every frame slightly wrong and read as a bug in the renderer.

Playing one

A sprite animation is an ordinary clip. Drag it into a controller graph like any .anim.ron: it gets a state, crossfades, transitions, layers and script control, and nothing downstream knows the difference. That is deliberate — two animation systems, one for sprites and one for everything else, is how a character whose sprite changes and whose node moves ends up being two clips that have to be kept in step by hand.

It plays on whatever wears a material: a ▫ Sprite node, or the Plane-plus-Material every 2D project built before there was one. The cell lands wherever that node actually reads it from — a Sprite's own cell, or the Material's.

The Animating tab plays a sprite animation and does not edit it: its frames, its frame rate and its holds live in the file, and the timeline writes keyed lanes, which is a different shape and a different filename. Edit the .spriteanim.ron.

Or key frames in the timeline

The quickest way in: open ⏱ Animating on the node, press ● Record, and change the material's texture. A stepped lane appears with a key at the playhead. Scrub, change it again, and that is the clip.

The other way in, for a clip where the sprite is part of what is animated. In the ⏱ Animating tab, ✚ Property ▸ Sprite.frame adds a sprite lane beside the transform lanes, and each key holds a whole frame — image, grid and cell picked together. One clip then carries the character's sprite and its movement, and the event lane already there fires the hit frame.

Beside it, ✚ Property ▸ Sprite ▸ Node keys what this node does with the picture rather than which picture it is: ppu and size for squash and stretch, pivotX/pivotY to shift the origin for a crouch, flipX/flipY to face the other way on a turn. So a whole turn-and-swing is one clip, and none of it needs a script.

A sprite lane never interpolates, whatever the file says — the conversion forces it. Half of one picture and half of the next is not a picture, and blending the cell indices, which is what a lane built on top of a plain number would do, silently plays every cell in between. That reads as the clip running at the wrong speed rather than as a bug, which is why it is ruled out by the type rather than by a checkbox.

The cost to know about

Frames on one sheet are one texture binding; a clip that hops between sheets rebinds per frame. That is a real cost and it is yours to spend — the engine says so rather than preventing it. Refusing to reference a second sheet is the restriction this replaces, and a later atlas-packing step can fold a clip's sheets into one without changing the clip.

The 2D camera

Tick 2D camera on an orthographic Camera node. It is the rule every 2D project ends up writing in Lua — chase the player, but not exactly, and not off the edge of the level — with the three parts that have a version that looks right and is subtly wrong already decided:

follow the name of a node to chase. Empty still gives you the limits and the shake: a fixed camera that cannot show outside the level.
dead zone how far the target may move before the camera moves at all. Without one, every footstep moves the camera and the world reads as wobbling.
smoothing seconds to close about two thirds of the gap — the same at 30 fps and at 144. 0 snaps.
limits a rectangle the camera stays inside, so it never shows past the end of the level.

They apply in that order, and the order is the design: the dead zone decides how much of the gap counts, smoothing closes that, and the limits clamp the result.

Smoothing is a time, not a speed. The version everybody writes first is lerp(camera, target, k * dt), which looks right on the machine it was tuned on and lags differently on every other one. This is exponential, so the lag is a property of the number you typed rather than of the frame rate.

Shake

lua
node:shake(0.35, 0.25)   -- world units, seconds

Shake is added to what is drawn and never fed back into the follow. That is what makes it compose: a shake written into the camera's transform is a shake the follow then chases and the limits then clamp, so it damps itself near a boundary and drags the camera off its target everywhere else. Here the follow keeps its own position and the shake rides on top, so a shake at the edge of a level still shakes.

Calling it again takes the louder amplitude and the longer time, each independently — so shaking every frame while something explodes cannot build an unbounded shake, and a bang cannot cut a three-second rumble short. And it is a function of the play clock rather than of a random number, so two machines simulating the same frame see the same camera and a replay looks like what happened.

From a script

lua
node:setCamera2D{ follow = "Player", smoothing = 0.12, deadZoneX = 1.5 }
node:setCamera2D{ follow = "Player2" }     -- hand it to someone else
node:setCamera2D{ follow = "" }            -- stop following, keep the limits
node:setCamera2D{ off = true }             -- take the behaviour away

Every key is optional and keeps what the node had — per axis, so naming deadZoneX alone leaves deadZoneY where it was.

Naming a different target restarts the follow from where the camera is, rather than from wherever the last target left it — otherwise handing the camera to a second character sends it travelling across the level to find them. Setting the same name again changes nothing, so calling this every frame is fine.

With no target the camera's position is left to whatever else is moving it — a script, a cutscene, your own placement — and only the limits and the shake are applied. That is what lets node:shake work on a camera you drive by hand.

On anything that is not an orthographic camera these settings do nothing — not on a perspective camera, and not on some other node you called shake on by mistake. The Inspector does not offer them: a follow that has to think about distance and pitch is a different rule, and showing these there is how somebody learns the wrong model. If you set one up and then switch the projection, the Inspector says so rather than leaving the numbers looking live.

▧ Sprite Batch — many sprites, one node

Add one from Hierarchy ▸ ✚ New ▸ ▦ 2D ▸ ▧ Sprite Batch, switch an existing node to one with Inspector ▸ Add Component ▸ Type ▸ ▧ Sprite Batch, or make any node into one from a script — which is usually what you want, because a batch is per material and a material is per style, and a game's styles are data:

lua
function start(node)
  node:setSpriteBatch{ size = 1.0 }   -- `size` is the quad's edge; sprites scale it
  node:setMaterial{ texture = "textures/bullets.png", sheetCols = 4, sheetRows = 4,
                    filter = "pixelated", unlit = true }
  batch = node:sprites()
end

function update(node, dt)
  for _, b in ipairs(bullets) do
    -- x, y [, z] [, scale] [, rot] [, cell] [, r, g, b, a]
    batch:draw(b.x, b.y, 0, 1, b.angle, b.frame)
  end

  for _, e in ipairs(enemies) do
    -- The point of a batch: THIS one is flashing, and nothing else changes.
    local red = e.hurt > 0 and 0.25 or 1.0
    batch:draw(e.x, e.y, 0, 1.5, 0, e.frame, 1, red, red)
  end
end

One batch per style, made where the style is declared — fifty styles in a Lua file need fifty batch nodes, and authoring those by hand into the scene, kept in sync with the Lua by nothing, is the duplication setTilemap exists to avoid for the other half of this pair.

node:sprites() on a node that is not a batch is an error that says so. It used to hand back a working-looking handle and quietly throw away every draw.

b:draw is immediate mode, the same contract as draw.* and gizmo.*: what you draw this frame is what shows, and next frame starts empty. There is no pool to grow, nothing to recycle, and no clear() to forget on the frame a wave dies.

The unit is the frame, not the pass. Draw from update, fixedUpdate, lateUpdate or any mix of them and everything drawn since the frame began is what renders. (Sprites drawn from a fixed pass that runs several times in one frame are drawn several times — same as draw.line. If that shows, move those draws to update.)

size is the sprite's edge in world units: size = 1 draws a 1-unit sprite, and each sprite's own scale multiplies it.

Positions are local to the batch node, so the node's transform still moves and orients the whole thing.

scale takes one number, or a vec2 when you want the two axes to disagree:

lua
-- The wind-up before a lunge: squat and wide, then tall and thin.
local squash = vec2(1 + e.telegraph * 0.5, 1 - e.telegraph * 0.4)
batch:draw(e.x, e.y, 0, squash, 0, e.frame)

Squash-and-stretch is how a 2D game telegraphs an attack, and while a batch could only scale uniformly it was the one effect that forced enemies back onto scene nodes — leaving a game maintaining two rendering paths for no other reason.

The tint is the feature

Colour otherwise lives on the Material, and a pool of quads shares one — so a game that wants to flash one enemy red cannot. The usual workaround is to blink the sprite off on alternate frames, which is a different effect, chosen because the right one was unreachable. A batch gives every sprite its own tint, which is also fades, status colours and damage numbers.

The tint multiplies the material's colour, so a white sprite takes the tint exactly and a batch that is already coloured is modulated rather than overwritten.

When a batch is not optional

Above a few hundred sprites, use a batch. This is not a style preference and the reason is not the GPU.

Measured with cargo run -p floptle-script --release --example sprite2d_lua_probe — the Lua pass only, no rendering at all:

sprites pooled nodes, written each frame pooled nodes, parked one batch
1,500 2.5 ms 1.1 ms 0.8 ms
5,500 10.6 ms 4.4 ms 3.0 ms

A 60 fps frame is 16.7 ms in total — physics, rendering and the rest of your scripts are in there too. At 5,500 sprites the pooled version has spent two thirds of the frame before anything has been drawn.

Read the middle column twice: those are nodes the game is not touching. A pool that grew for one boss pattern and never shrank keeps charging you every frame, because every node in the scene is walked once a frame whether or not anybody moved it. If you are pooling scene nodes, node:destroy() the ones you are done with — a batch sidesteps the question, since there are no nodes to release.

(The companion sprite2d_probe in floptle-render measures the GPU side and correctly finds almost no difference: 1,400 quads is 1,400 quads however they are gathered. Both numbers are real; this is the one that decides your frame rate.)

Sorting layers — what draws in front

A 2D scene is flat, so what draws on top is depth, and the way to say it used to be Z by hand: the floor at 0.001, the player at 0.002, the HUD prop at 0.003. That works until you want something between two of them, and then you edit every number above it — and none of the numbers say what they mean.

Name the layers instead. Project Settings ▸ Layers ▸ Sorting layers, back to front:

Default          (always first, always exists)
Background
Terrain
Characters
Foreground

Then a node picks one in the Inspector, with an order inside it — higher draws in front, negatives are fine. Two props on Characters at order 0 and 1 are settled without either of them touching Z.

  • Layers are referenced by name, so reordering the project's list never silently re-sorts a scene. (The same reason collision layers are by name.)
  • order cannot climb out of its layer. Set it to a million and it still draws behind everything on the next layer up.
  • Nothing moves. A sorting layer is a Z offset on the drawn transform, so a collider stays where you put it and a script reads the position it set.
  • A node with no sorting layer is on Default at order 0 — exactly the Z it has always had. A scene that does not use this is unchanged, byte for byte.
  • A layer you delete leaves nodes that named it drawing in front rather than vanishing behind the background: visibly wrong beats mysteriously missing. The Inspector says so on the node.

Sorting layers are for ordering things at the same depth. For layers that should move at different speeds, see Parallax below.

From a script

lua
node:setSorting{ layer = "Characters", order = 3 }
local s = node:sorting()          -- { layer = "Characters", order = 3, mode = "order" }
node:setSorting{ order = s.order + 1 }   -- one in front of where it was

Every key is optional and keeps what the node had. A node that has never said anything about sorting reads back as { layer = "Default", order = 0, mode = "order" } rather than nil — that is genuinely its answer, and nil would make every caller write the same three lines of fallback before it could add one to a number.

Y-sorting — lower on the screen draws in front

A top-down game's depth is not a number anybody wants to author. A character standing below a table is in front of it; one standing above it is behind; and which of those is true changes every time they walk. Set the node's sorting mode to by Y (Inspector, or mode = "y" from a script) and it follows from where the node is:

lua
node:setSorting{ layer = "Characters", mode = "y" }

The sort is layer, then order, then Y

Y is a tiebreak, not a replacement. In full:

  1. Sorting layer — always, for every sprite. Later layers draw in front.
  2. order within that layer. Higher draws in front, exactly as before.
  3. Y, and only for nodes that are level on both of the above.

That order is what makes the feature usable rather than all-or-nothing. A character's drop shadow sits on order = -1 and stays under the whole crowd however they move; the character is on order = 0 and Y-sorts against the props beside it; a held lantern is on order = 1 and stays in front of its owner. None of those three could be expressed if Y overrode the number.

  • order stays live in the Inspector under both modes, because it still means what it always did.
  • It cannot climb out of its layer, exactly as a large order cannot. A Y-sorted character at the very bottom of the screen is still behind anything on a layer in front of it. That is what keeps a foreground from being overtaken by whoever walks closest to the camera.

A sorting layer is not a collision layer. They are two different lists with two different jobs — "which draws in front" and "what does this hit" — and a scene routinely wants a background that collides with nothing and still sorts. The Inspector labels them sorting layer and collision layer for this reason; Project Settings keeps them as separate lists for the same one.

  • There is nothing to tune, and no range to get wrong. Y-sorting ranks a layer's nodes against each other rather than mapping their coordinates onto a scale, so a level built around the origin and one built at y = 4000 sort identically, and moving the camera changes nothing. It also means the Scene view and the Game view cannot disagree, because neither answer depended on a camera.
  • Nothing moves. Like the rest of sorting, it is a Z offset on the drawn transform: the collider stays where you put it and a script reads the position it set.

How many nodes one layer can Y-sort

About 128, and then the ones adjacent in the sort may tie.

That is a budget for the whole layer, spent on the nodes actually in it rather than divided between orders in advance — so one order with a hundred characters in it gets the lot, which is the case that matters.

That number is measured rather than reasoned about. Sorting resolves to a Z nudge, an opaque surface is settled by the depth buffer, and under the orthographic camera the depth range is deliberately huge (±10,000 world units, so a flat game can put its art on the camera plane). sort_precision_probe sweeps overlapping quads through that camera and reports the smallest Z difference that puts the nearer one in front drawn both ways round — one order step separates, half a step does not. A layer is 64 order steps wide, so a layer holds on the order of a hundred distinguishable depths.

cargo run -p floptle-render --release --example sort_precision_probe

Worth running rather than trusting: the arithmetic from the depth format alone gives a fifth of the real answer. If you have more than a hundred-odd characters that must all sort against each other, split them across two sorting layers — the layers themselves are nowhere near that limit.

Parallax

A background that moves more slowly than the world reads as further away. Set the node's parallax factor — the fraction of the camera's movement it keeps:

lua
hills:setParallax{ x = 0.3 }        -- distant: moves at three-tenths speed
sky:setParallax{ x = 0.0 }          -- pinned to the camera; infinitely far
grass:setParallax{ x = 1.0 }        -- the default: moves with the world

Per axis, because a side-scroller usually wants horizontal parallax and no vertical drift at all. The Inspector has the same two numbers beside the sorting layer.

Why it is a factor and not a distance

Because the other way does not work under the camera a 2D game wants.

Under a perspective camera, a layer two units further back is drawn slightly smaller and moves slightly less — that is parallax, for free. But it also means your parallax layers change scale, which is exactly what an orthographic camera exists to stop: camera.pixelsPerUnit() is constant under orthographic projection, and that constant is what keeps pixel art on its grid.

So the engine used to offer a choice nobody should have to make: crisp pixels, or a moving background. A scroll factor is not a distance, so it works identically under either projection and the choice goes away.

If you are on a perspective camera, real Z still parallaxes and this composes on top of it. That is two effects on one layer, which is allowed and is almost never what you meant — pick one.

Nothing moves

Like a sorting layer, parallax is an offset on the drawn transform. The node stays where it was authored: its collider does not drift, a raycast hits where the level says, and node.x reads back the number you set. A parallax layer you can walk into is a parallax layer that is wrong, and this is why that cannot happen.

The anchor is the world origin — a layer sits exactly where you placed it when the camera is at 0. That has to be some fixed point, and the origin is the one the rest of a flat scene is already built around.

Pixels per unit

lua
local px = camera.pixelsPerUnit()             -- constant under an ortho camera
node.x = math.floor(node.x * px + 0.5) / px   -- snap to whole pixels

Every 2D project used to derive this from the FOV and the camera's Z by hand. It is the projection, which the engine already knows, so now it comes from the engine.

Under an orthographic camera the distance is not in the answer, and the optional argument is ignored — the view is the same height everywhere, which is the whole reason a flat game uses one. Under a perspective camera it is measured at that distance, defaulting to the camera's distance from the origin.

Let the camera do the snapping

The snippet above is what every 2D project writes. A 2D camera can do it for you, and do it better — it snaps what is drawn and keeps the follow's sub-pixel place, so the camera can still creep slower than a pixel a frame instead of sticking:

lua
cam:setCamera2D{ follow = "Player", pixelSnap = 32 }   -- 32 px per world unit

or in the Inspector, Camera 2D ▸ snap to whole pixels. The number is the same one a ▫ Sprite's pixels per unit uses. Without it a camera that stops between two pixels resamples every sprite by a fraction of one, and the art shimmers along its edges while nothing in the scene is moving.

The settings a pixel-art game should ship

A player of one of ours put it best, unprompted: "the game visually looks totally different whether I have the game window fullscreened or not." They were right, and it was two separate things. Both are now settings.

In Project Settings ▸ Rendering, with Retro on:

Setting Ship it as Why
Pixel rows your design height (e.g. 240) the vertical resolution you draw for
Pixel columns a fixed number (e.g. 426) 0 derives the width from the window, so a 2.0-aspect panel shows 12% more of your level than 16:9 — for a game you have balanced, that is a difficulty setting nobody chose
Whole pixels on upscale by a whole number and letterbox the rest

And on each UI layer (Inspector ▸ UI Layer): tick pixel scale.

Why "whole pixels" matters more than it sounds

Without it the composite is stretched to fill whatever rectangle it is given. Fullscreen at 1440p that happens to be about 6×; in a docked Game panel 486px tall it is 2.025×, so some source rows land on two screen pixels and some on three. On 1-bit art with an 8px font that is the difference between crisp and mush — and which rows are fat changes as you drag the window edge.

With it on, every source pixel is exactly the same size, and the remainder becomes black bars.

Pin the width if you turn this on. A derived width is rounded to a whole pixel, and that rounding can cost you a whole step: 240 rows at 16:9 rounds to 427 columns, six of which is 2562 — two pixels too wide for a 2560 window — so it drops to 5× and leaves a sixth of the screen black. A pinned width that divides your target resolution does not have this problem.

The same rule, one layer up

A UI layer's scale is window height / design height — the same fractional number, so a 240-unit HUD in that 486px panel scales by 2.025 and its pixel font is resampled off its own grid. pixel scale rounds it down to a whole number.

Rounding down rather than to the nearest is deliberate: the design canvas comes out slightly larger (243 units instead of 240) and the extra is margin, so anchored elements stay against their edges and centred ones stay centred. Nothing is offset, so nothing clicks in the wrong place.

When you can't letterbox: text snap

pixel scale fixes everything on the layer at once, and the price is that it letterboxes at non-integer multiples. If your HUD has to fill an arbitrary window and only the text is pixel art, snap the text instead.

Set text snap (Inspector ▸ UI Layer, or layer.textSnap from a script) to the number of cells in your font's em — 10 for the usual tenth-of-an-em grid. Every rasterized text size then rounds to a whole multiple of it, so a cell is always a whole number of screen pixels.

Why it is needed at all: the engine already rasterizes at an integer pixel size, which is necessary and not sufficient. What reaches the rasterizer is text size × layer scale, and that scale is the window's — 1252 rows against a 720-unit design is 1.7389, so a size: 24 label rasterizes at 42 px, which is 4.2 pixels per cell. There is no size you could have chosen instead, because the scale is not yours and does not hold still.

This is the one that gets misdiagnosed. It was reported as "each character looks like it's just not positioned exactly correctly" — and nothing is mispositioned. The layout can be exact integers and the text still reads as badly spaced, because the distortion is inside each glyph rather than between them: every vertical stem straddles a pixel boundary by a different fraction and takes a different amount of antialiasing, so the same letter comes out different in two different words. If you go looking in the positioning code you will find it correct.

Off (0) by default, and a layer that leaves it off rasterizes and wraps exactly as it always did.

Generating your own pixel font? Check the em, not just the cell. A font built at UPM = 1024 with CELL = UPM // 10 has cells of 102/1024 — 0.0996, not 0.1 — and then no pixel size makes a cell whole, snap or no snap. Use a UPM your cell count divides (1000 for ten cells).

Sheet cells never bleed

A cell's UV window used to share an exact edge with its neighbour, and a linear sampler asked for a texel on that edge blends across it — a rim of the next frame around your sprite, at some scales and not others. Every sheet window is now pulled in by half a texel, which is inside the cell's outermost pixel: nearest sampling is unchanged, and linear sampling can no longer reach over the line. Plain (non-sheet) textures are untouched.

Set filter to pixelated on the Material for crisp pixel art.

What this is not

There is no 2D physics layer, no sprite editor and no animation state machine here — those are all things a game does well in Lua today. This is specifically about how sprites and tiles reach the screen.

An orthographic camera mode now exists on the Camera node (projection = 'orthographic', plus orthoHeight) and in the editor's Scene view — see tilemaps.md §6. Use it: under perspective a layer two units further back is drawn slightly smaller, so parallax layers change scale as well as speed and cannot be lined up. camera.pixelsPerUnit() answers for either projection — under an orthographic one it is constant, which is the whole point of using it.

A pinned width decides the framing. With retro width set, the camera projects at the target's aspect rather than the window's, so what is in shot stops depending on the window shape at all — which is the reason to pin one. Left at 0 the width is derived from the window and the framing follows it.

Integer scaling letterboxes the world; the game UI is drawn over the finished frame at the panel's own size, so a HUD still spans the whole view rather than being confined to the image. pixel scale on the layer is what keeps its own glyphs on the grid.

See also

  • tilemaps.md — the ◫ Tiles authoring suite: painting tools, palettes, tilesets (per-tile collision, tags, animation), autotiling, and the orthographic camera.
  • lua-api.md — the 2D — sprites, sorting & the flat camera group.
  • tutorials/flappy.md — a flat game built the simple way, with ordinary nodes. Worth reading first if 2D is new to you.
  • cargo run -p floptle-render --release --example sprite2d_probe — what the room and the sprites cost on the GPU, measured.
  • cargo run -p floptle-script --release --example sprite2d_lua_probe — what they cost on the Lua side, which is the number that decides your frame rate.

2D lighting

A PointLight node in a scene whose active camera is orthographic is a 2D light. It lights tilemaps and sprite batches, and it does not light meshes — a light belongs to one system or the other, so a 3D prop that wanders into a flat scene is not washed over by the torches.

Nothing about that is guessed twice. Every node carries a three-valued flag (Inspector ⏵ 2D light): auto, 2d, 3d. auto is the only one the engine decides, and it shows you what it decided and why — auto → 2D — the active camera is orthographic. Saying 2d or 3d is never re-decided, so a scene changing shape around a node cannot change what you said about it.

Which layers a light reaches

A 2D light lists the sorting layers it lights. A new light reaches all of them; untick one to keep it out — a background that should stay flat while a torch passes over it is the usual case, and it is one tick.

This is not the collision layer mask. A background that collides with nothing and a player that does sort — and light — independently of that.

The base light

A 2D surface with no light near it is drawn at the 2D base light — the Lighting node's 2D base light, next to the 3D ambient it is deliberately separate from.

It is white by default, so placing a light can only ever make your scene brighter. That is the whole point of it being its own field: a first light that dropped the level to the 3D ambient would read as the feature having broken your game, which is exactly how it was reported.

Turn it down to get a dark room for a torch to carve a circle out of. That is the deliberate act, and it is one colour picker.

Opacity is yours

A tilemap or sprite batch at alpha 0.72 reaches the screen at 0.72, lit. 2D lighting changes a surface's colour and never its opacity, so the number you tune is the number a player sees.

That is worth saying out loud because it was briefly not true: between v0.38.0 and v0.39.1 the lighting pass composited a translucent surface a second time on top of the one the ordinary pass had already drawn, so an authored a arrived as 1 - (1-a)² — 0.5 drew at 0.75, 0.72 at 0.92. Nothing an author could look at disagreed, which is what made it expensive.

A 2D-lit surface is unlit in 3D terms. Being on the 2D path means the scene's sun and its 3D point lights do not shade it — the 2D lights are what light it, which is the whole separation. If you want a flat surface shaded by the 3D lighting instead, set its 2D light mode to 3d.

Only the lights you placed cost anything

A flat surface is filled into the lighting pass only if some live 2D light's layer list actually reaches its sorting layer. A scene with no 2D light placed does no 2D lighting work at all, and a light restricted to Ground costs nothing for the layers it does not name.

perf.counts().flat2d is that number — how many flat surfaces the pass is rasterizing a second time this frame. It reads 0 for a scene that has not opted in, which is what makes it worth checking.

A light that names no layers reaches all of them, so the ordinary case of dropping one light into a scene behaves exactly as you would expect: everything flat is lit, and everything flat is filled. Turning the 2D base light down also reaches everything, because a dimmed room is dim with no torches in it.

There is no 2D directional light

Only PointLight is on the 2D path. A Lighting node's direction still drives 3D shading only — for a flat scene, the base light above is what a "sun" would otherwise be, and it has no direction.

Walls stop light

Under blocks light: auto a tilemap casts exactly where it is solid — from the colliders its tileset already declares. So a level's collision is its light occlusion, from one piece of data, and the cover that stops a bullet is the cover that stops the light. Nothing to author twice and nothing that can drift apart.

The other two answers are absolute: on makes anything cast, including a sprite batch that collides with nothing; off stops a collidable tilemap casting. The Inspector shows which way auto went and why.

Two rules that follow from the layer mask, both of which you would notice if they went the other way:

  • A light that skips a layer is not blocked by it. A torch you kept off the background must not have the background throwing shadows into the room.
  • A wall's lit face is lit. Only what is behind an occluder goes dark, so a wall reads as a wall rather than as a silhouette.

Nothing is rebuilt when a light moves, which is what makes this usable in a game where every light does: the player's follows them, a muzzle flash is at the gun, a death burst is where something died. The cost follows the lit area — a pixel outside a light's radius does no work for it — and a scene with no casters in it pays exactly what it paid before shadows existed.

If you want one light to pass through everything anyway — a glow that is not meant to be a light source, a UI pulse — untick casts stop this light on it, or shadows = false from a script.

Shaping the falloff

A light is full brightness out to full out to (its inner radius) and then falls to nothing at its range, along a curve of your falloff exponent. The defaults — 0 and 2 — are the ramp every light has always had.

Between them you get anything from a soft glow that reaches a long way to a hard pool with a defined edge. An inner radius at 0.8 × range puts the entire falloff in the outer fifth: a bright disc that ends.

lua
local torch = find("Torch")
torch:setLighting2D{ inner = 6.4, falloff = 2 }   -- range 8: a hard-edged pool

Posterize and light

A 2D light is smooth, whatever else your scene is set to. Turn posterize on at any band count, dither on or off, retro on or off — the light will not band, and you do not have to find a setting to get that.

That is worth stating outright because it used to be the opposite. Posterize quantizes your palette: the set of values your art is allowed to be. A light is not one of those values — it is a multiplier on whatever value your art is. While the quantize was the last thing to touch the frame, the two were the same setting, and no configuration was right:

bands dither the light
8 off hard concentric rings
8 on a stipple; it reads as a dither pattern, not as light
off smooth, and you lose the palette you turned posterize on for

Posterize now runs over your art, before any light reaches it. Your tiles land on their bands and your lights ride on top. Everything else that is shaped like light is downstream of that quantize too — the vignette, bloom, ambient occlusion — so a vignette is a smooth darkening again rather than a set of rings in the corners.

Dither is for art, not for light. It trades a hard step for a stipple in a gradient your palette cannot hold — a painted sky, a soft-edged sprite. It has nothing to do with lighting any more, and turning it on will not change how a light looks.

A warm ramp does not band into hues

If you posterize, tick step brightness, keep colour on the PostProcess node (posterizeChroma from a script).

Quantizing each channel on its own is a real look and stays the default. It is often not what warm art wants: a gradient crosses each channel's band boundary at a different value, so a sunset or a torch-lit wall at {1.0, 0.86, 0.62} steps through colours nobody chose — olive where red and green have stepped and blue has not, maroon where only red has.

With the setting on, the step happens once to brightness and the colour rides along, so your art steps in brightness and keeps its hue. An exactly grey pixel comes out identical either way, so switching it on cannot move art that was already neutral.

What is not built yet

Normal maps, height, and a 2D directional light.