# 2D: cameras, sprites, tiles, layers, lights, particles, text

Sindri AI knowledge pack, file `06-2d.md`. How 2D works in Sindri: the 2D model, depth and draw order, cameras, grids, tilemaps and terrains, sprite shaders and masks, 2D lights, particles, nine-slice, world text and transparency.

Start with `00-start-here.md` if you have not read it. Every fact here is copied from the Sindri repository; nothing in this file is a guess.

## Contents of this file

- **How Sindri does 2D** (from `docs/2d-model.md`): One transform; What Z means; Sorting is not the depth buffer; Physics ignores Z, and that is a hazard rather than a convenience; Keeping Z where you put it; Pixel-perfect requires an orthographic camera; Screen space is a different question; Scene format; What this removes from the plan; What it does not answer yet
- **Depth and placement in 2D** (from `docs/2d-depth-and-placement.md`): The open question; The measurement; What this costs today; Invariants; Deriving depth from the grid; Grid-anchored placement; The tile volume stops being special; Ground is not a wall; What a placement covers; Standing everywhere; Seeing it; What one point per sprite cannot do; Migration boundary; What this does not decide; Acceptance test
- **Camera semantics** (from `docs/cameras.md`): Authored cameras; Gameplay camera behavior; Third-person 3D orbit; Screen-space UI and overlays; Editor Scene camera; Scene format 7; Causeway quarter views
- **Grid coordinates and projection** (from `docs/grid.md`): Coordinate convention; What came from the earlier projects; Current and next
- **Tile System 2** (from `docs/tile-system-2.md`): Invariants; Four levels; Derived rendering; Authoring; Migration boundary; Acceptance test
- **Terrains** (from `docs/terrains.md`): In the sheet; Corners; Painting; Limits; Proof
- **Sprite shaders** (from `docs/sprite-shaders.md`): What a project writes; Authoring; Hosts; Failure; Limits; Proof
- **Sprite masks** (from `docs/sprite-masks.md`): Authoring; How it draws; Limits; Proof
- **2D lights and shadows** (from `docs/2d-lights.md`): Turning it on; Lights; Normal maps; Shadows; In the editor; In Decay; Proof
- **Particles** (from `docs/particles.md`): Fields; Determinism; In Decay; In the editor; Limits; Proof; Particle Lab
- **Nine-slice sprites** (from `docs/nine-slice.md`): The border is the art's; Drawing sliced; In the editor; Proof
- **World text and rich text** (from `docs/world-text.md`): `sindri.text`; Rich text; Proof; Limits
- **Transparent rendering policy** (from `docs/rendering-transparency.md`): Blend modes; Draw order; Depth is read, never written

---

Source: `docs/2d-model.md`

## How Sindri does 2D

> **Design record:** the shared 2D/3D transform direction remains current, but
> statements below about what the implementation has “today” describe the
> pre-migration state in which this decision was made. Use `capabilities.md` for
> current behavior.

2D is not a separate world. There is one world, one transform, and one set of
spatial rules; a 2D game is one that keeps everything on a plane and points a
camera at it.

This is the Unity model, and it is chosen for a reason that is specific to this
project rather than borrowed: `PROJECT_OVERVIEW.md` already requires that 2D and
3D share infrastructure, and the current design does not honour that. Today an
entity has a `Transform2D` **or** a `Transform3D`, so a sprite and a cube cannot
occupy the same space at all — which makes "put a 3D prop in the sprite scene"
impossible rather than merely unbuilt.

### One transform

`Transform3D` is the transform. There is no separate 2D one.

A 2D entity is an entity whose Z happens not to change and whose rotation
happens to be about Z. Nothing enforces that, because nothing needs to: the
2D-ness lives in the components an entity carries, not in its transform.

`Transform2D` is `Transform3D` with the Z position missing, and the missing Z is
the entire problem — it is what makes a 2D scene unable to have depth, and
therefore unable to have parallax or to share space with anything 3D.

### What Z means

**Z is a position. That is all it is.** Everything else is interpretation, and
each interpreter is explicit about what it does with it:

| Interpreter | What it does with Z |
| --- | --- |
| Physics | Ignores it entirely |
| Orthographic camera | Sorts by it; apparent size and position unaffected |
| Perspective camera | Sorts by it; things further away look smaller and move less |
| Transparent ordering | Derives a sort key from camera distance |

The consequence worth stating plainly: **the same scene under two cameras is the
same game.** Orthographic gives you a flat presentation, perspective gives you
parallax, and no content changes between them. Parallax is therefore not a
system to build — it is what a perspective camera does to layers that are
already at different depths.

#### The trap that comes with that

An orthographic camera still clips against its near and far planes. A
background layer at Z = -50 is invisible under an orthographic camera whose far
plane is 10, even though Z does not affect its size. So the orthographic
camera's depth range must contain the Z range the scene actually uses, or
switching cameras silently loses the background.

The renderer's low-level `OrthographicCamera` still has generic projection
defaults, but an authored orthographic `sindri.camera` is a world camera and its
near/far range belongs to the scene. Screen-space UI does not borrow those
planes because it does not use an authored camera at all.

### Sorting is not the depth buffer

Sprites are transparent, and transparent geometry cannot rely on depth testing —
blending is order-dependent, so a depth buffer gives you whichever result the
draw order happened to produce.

So explicit back-to-front ordering stays. `TransparentOrder` orders by layer,
then depth, then insertion; what changed is where the depth comes from. It is no
longer a hand-authored `depth` field on the sprite — it is the distance from the
camera, measured along the camera's forward axis so that two sprites side by
side at the same depth sort as equally far away.

A UI element is the one place where that distance is not where the thing is
drawn. The viewport-owned screen projection reads X and Y for placement, so a
HUD element's Z orders it without moving it — which also means no
HUD can be lost off an authored camera's far plane by being pushed a long way
back.

**Precedence, decided once:** layer first, then camera distance, then insertion
order. A layer is an explicit authored override, so it wins over geometry; two
things in the same layer sort by where they actually are; and insertion order
breaks the remaining ties so a frame is deterministic. This is the rule Unity
has too, and it is the one people are surprised by — a sprite in a higher
sorting layer draws in front of something nearer the camera. Being surprised
once is the cost of being able to override at all.

### Physics ignores Z, and that is a hazard rather than a convenience

2D physics operates on the XY plane. A body's Z position does not affect
whether it collides with anything.

This is the right behaviour and it is also the model's sharpest edge, because Z
is now doing presentation work. A parallax background at Z = -50 and a player at
Z = 0 are, to 2D physics, in exactly the same place. Put a collider on that
background and the player walks into it.

**So collision filtering cannot use Z, and needs its own axis: collision layers
and a matrix saying which collide with which.** That is not a later refinement.
The moment Z means parallax, Z stops being usable as "these things are
elsewhere", and something has to replace it on day one of 2D physics.

**Collision layers are not render layers.** `RenderLayer` already exists and
decides draw order. Conflating the two is a standard engine mistake and the
names must stay apart from the beginning, because they answer different
questions and will eventually want different values.

#### What a 2D body may write

A 2D rigidbody owns exactly three numbers on its transform: **X, Y, and rotation
about Z.**

It must never write the Z position — the first physics step would otherwise
flatten every parallax layer into the play plane. It must not write X or Y
rotation, and it must not write scale.

Stated as a rule rather than an implementation detail because it is the kind of
thing that is easy to get right once and then lose in a refactor, and the
failure is silent: the game still runs, the background is just quietly in the
wrong place.

### Keeping Z where you put it

Physics is not the only thing that can flatten a scene. Scripts, animation, and
parenting all write transforms, and the classic way a layered 2D game collapses
is a line like `position = (x, y, 0)` written by someone who was only thinking
about X and Y. Two mechanisms guard against it, and they are not equally strong.

**The 2D-shaped API has nowhere to put a Z.** A 2D transform accessor takes and
returns two numbers; setting a position in 2D reads X and Y, writes X and Y, and
cannot express a change to Z because the signature has no third argument. The
same goes for a 2D body's write-back: it does not take a Z to ignore, it takes
an XY and an angle.

`Transform3D` carries them: `position_2d`, `set_position_2d`, `translate_2d`,
`scale_2d`, `set_scale_2d`, and the pair for the turn about Z, which is the only
turn a flat thing facing the camera has. The three-dimensional fields are still
there and still public, because sometimes a 2D thing genuinely needs to change
layer; what the 2D calls do is make that a thing you say on purpose.

This is the one that actually works, because it is not a rule anyone has to
remember. Anyone thinking in 2D reaches for the 2D call, and the 2D call is
incapable of the mistake. It is the same reasoning that made colour space a
shared constant rather than a convention.

**An explicit Z lock, for everything else.** The 3D API still exists and can
still write Z, which is correct — sometimes a 2D thing genuinely needs to change
layer. A transform may declare its Z locked, and checked write paths respect it.

Be honest about the difference: this is a check, not an impossibility. It holds
for writes that go through a path that can check, and a direct field assignment
bypasses it. It earns its place by being visible in the inspector and by saying
what the author meant — "this stays on its layer" — rather than by being
airtight. The API shape above is the real defence.

It lives on the transform, as `z_locked`. The alternatives were a component of
its own and a flag on the 2D body, and the transform wins because the lock is
about one number on the transform: a component would have to be looked up by
every path that writes a position, and a body would only cover the entities
that have one, which is the wrong half — the scripts and animations that
flatten a scene mostly belong to things with no physics at all.

What respects it is `WorldCommand::SetTransform3D`, which is the single write
path tools use, so the check sits where the editor and other command-driven
authoring paths pass through. The Decay host enforces the same lock at its world
boundary. A refused command changes nothing
and never enters the history, so a transaction that contains one rolls back
whole.

Removing a transform counts as moving it. An entity without one is at Z = 0, so
dropping a locked transform lands a parallax layer in the play plane exactly as
writing a different number would.

### Pixel-perfect requires an orthographic camera

Pixel art wants one texel to land on one pixel. Under a perspective camera it
cannot, because apparent scale depends on Z, so different layers land on
different fractions of a pixel.

This is not a limitation to engineer around; it is why Unity keeps both cameras
and why Hollow Knight can use a perspective one — it is painted at high
resolution rather than being pixel art. The choice belongs to the game.

`ROADMAP.md`'s pixel-snapping item is therefore an orthographic-camera feature,
and should say so rather than being attempted generally.

### Screen space is a different question

A HUD is not in the world at all, so it is not on the 2D/3D axis and it is not a
property of the world camera. It is a property of **which component draws it**:
`sindri.ui.image` is anchored to a viewport edge and ignores authored cameras
entirely, while `sindri.sprite` is placed by its transform, drawn through the
world camera, and hidden by opaque geometry in front of it.

This started as one component with a `space` field, and that was wrong in a way
worth writing down, because the same mistake is available in every engine. The
two spaces do not hold the same fields: an anchor is what a HUD element is
positioned by and decides nothing at all for a thing in the world. So the
inspector hid the anchor on a world sprite — which is the clearest possible
statement that these were two components sharing a name. A component that has to
hide half of itself depending on one of its own fields is two components, and
they are now spelled as two. `docs/scene-extraction.md` holds the table of what
each draws.

That split is also what gives a scene two kinds of entity, in the way a Unity
scene has objects and UI. Nothing declares which an entity is: carrying a
`sindri.ui.*` component says it, the editor groups the hierarchy by it, and the
Add Component menu offers one family or the other rather than letting one
transform be drawn twice in two spaces.

The two are separated at the batch, not at the draw: a UI image and a world
sprite never share a draw call, because they differ in projection and pipeline.
Each sorts within its batch by its own depth rule, so a world sprite's order
changes when its camera moves while a HUD's remains tied to screen-space Z.

### Scene format

Replacing `Transform2D` with `Transform3D` changes the serialized format, so
this is scene format version 2 and the first real use of `SceneMigrator`, which
was deliberately built before a second version existed.

The migration is mechanical: a `transform_2d` becomes a `transform_3d` with
Z = 0, its `rotation_radians` becomes a quaternion about Z, and its two-component
scale gains a Z of 1. Nothing is lost.

One case is not mechanical, which this document originally missed. Format 1
allowed an entity to carry both transforms, and they described positions in
different spaces, so there is no merge of them that is reliably the same scene.
The migration refuses such an entity and names it, rather than preferring one
and moving something without saying so.

Golden fixtures cover both versions after this: a version 1 document must still
load, and must produce exactly the version 2 document the migration promises.

### What this removes from the plan

- **Parallax** stops being a system to port and becomes a consequence of depth
  plus a perspective camera
- **Milestone 9's dual presentation** — its exit gate asks that shared gameplay
  logic support both sprite isometric and orthographic 3D. Under one world with
  one transform that is a camera choice, not two presentation paths
- **Mixing 2D and 3D** stops being a feature at all, which is what makes
  Milestone 8's "3D prop in the sprite scene" trivial rather than impossible

### What it does not answer yet

- Whether sprites become real quads in the 3D pipeline or keep a dedicated
  sprite path. Sorting and batching argue for keeping the sprite path; sharing
  argues for quads. Decide when there is a reason to, not before
- Where collision layers are defined — project settings, the scene, or both
- How the editor presents a Z that matters for depth but not for gameplay,
  which is a real authoring problem Unity has never fully solved.
  `docs/2d-depth-and-placement.md` proposes an answer for anything standing on
  a grid: the author states a cell and the transform is derived from it, Z
  included, so there is no depth to present separately

---

Source: `docs/2d-depth-and-placement.md`

## Depth and placement in 2D

Status: implemented for grids. `sindri.grid.placement` derives a transform
from a cell, `depth_step` turns projected depth into Z, and Gather's
seventy-four authored layers are gone. What remains unbuilt is listed under
*What this does not decide*. This continues `docs/2d-model.md` and answers the
question it left open.

Sindri is not going to be the best 3D engine. It can be the best 2D one.

That is a position rather than a consolation. Most engines reach 2D by turning a
3D camera side-on, so their 2D is an arrangement over a general renderer and
their answer to depth is "sort by Y yourself, in game code". Sindri decided
something better in `docs/2d-model.md` — Z is a position, the camera sorts by
it, and parallax is a consequence of depth rather than a system to build — and
then never connected it to the thing a 2D game actually has: a grid.

This note connects them.

### The open question

`docs/2d-model.md` ends by naming what it did not answer:

> How the editor presents a Z that matters for depth but not for gameplay,
> which is a real authoring problem Unity has never fully solved.

Gather answered it by not answering it. Every prop sits at `Z = 0` and carries a
hand-written `layer` instead, and because the decided precedence is *layer
first, then camera distance*, those authored numbers now beat the depth system
outright. Seventy-four overrides, in a scene whose depth model was supposed to
be geometric.

The answer this note proposes: **the author never manages Z.** They state a
cell. Placement derives the transform from it, Z included, and depth follows
from position — which is what `docs/2d-model.md` wanted in the first place.

### The measurement

Every layered draw in every scene checked into this repository:

| Scene | Draws | Range | Distinct |
| --- | --- | --- | --- |
| `game/assets/gather.scene` | 89 | 0..120 | 45 |
| `games/shapes-lab/assets/shapes-lab.scene` | 89 | -120..30 | 23 |
| `games/orbital-baked/assets/orbital.scene` | 72 | -200..140 | 10 |
| everything else | 72 | 0..101 | ≤ 3 each |

Orbital's ten layers across a wide range are *groups*: backdrop, play field,
effects, interface. That is what a layer is for, and nothing here asks those
scenes to change.

Gather's forty-five across a narrow one are a *depth*. They are not arbitrary
either — every one of its seventy-four world sprites sits at exactly

    layer = 1 + 2 × (column + row)

which is the isometric diagonal, computed by hand and frozen into the file.

One field, two jobs. Counting the distinct values tells you which job a scene is
asking of it, and a scene asking for the second one is a scene with no other way
to say where something stands.

### What this costs today

All of it observed, none predicted:

- seventy-four integers that must be right by hand, correct only because a test
  asserts the formula;
- a tile volume that needed a `layer_step` field invented for it, purely to
  reach the axis the sprites had already taken over;
- a ground plane that moved down one level, after which every integer was stale
  and the props stood inside the floor;
- a prop and a block beside it sorting by two integers, when a sprite is taller
  than the cell it stands on.

The last is not a bug. It is the shape of the problem, and the section below on
single-point depth says what it does and does not solve.

### Invariants

1. Depth is a consequence of position. Z is the depth axis, as
   `docs/2d-model.md` decided; nothing authors a second one beside it.
2. Position on a grid is stated as a cell. The transform — X, Y and Z — is
   derived from it.
3. `layer` groups. It remains an override and keeps winning, because overriding
   is what it is for; it stops being how ordinary scenery is ordered.
4. A ground anchor is the one point deciding both where a sprite is drawn and
   how deep it is. Two authored values that can disagree is one too many.
5. Depth does not change because art changed. Redrawing a tree taller does not
   move the tree.
6. Elevation raises a thing on screen without moving it toward the viewer.
   Already true of tile volumes; it must become true of everything.
7. Orthogonal and isometric share the rule. The projection supplies the
   arithmetic; it does not get a rule of its own.

### Deriving depth from the grid

A grid already puts cell `(c, r)` on the plane at:

    orthogonal   y = -r · h
    isometric    y = -(c + r) · h / 2

Descending `y` is ascending `r` in one and ascending `c + r` in the other — the
row order and the isometric diagonal. `TileGridComponent::depth_key` already
computes exactly these two for tile volumes. A grid therefore already knows how
far into the scene each of its cells is; no new geometry is required, only the
decision to let that number reach the transform.

A cell further from the viewer takes a Z further from the camera. The scale is
the grid's to state, and two constraints bound it: the orthographic camera's
near and far planes must contain the range (the trap `docs/2d-model.md`
describes), and the step between adjacent cells must stay large enough to
survive `f32` comparison across a big map.

Under a perspective camera the same Z produces parallax, for free, exactly as
`docs/2d-model.md` predicted — with the honest consequence that a distant cell
also draws slightly smaller. That is a property of the camera, and a game that
does not want it keeps the orthographic one.

### Grid-anchored placement

`sindri.grid.occupant` derives a cell *from* a transform: the scene authors a
world position and the runtime works out where it landed. Everything in Gather
is placed that way, which is why a standing stone can straddle the boundary
between two cells, and why nothing moved when the ground beneath it did.

The inverse is missing. A placement component names a grid and a cell, with an
optional offset inside the cell, and the runtime derives the transform —
including the surface height, which `TileSurfaces` already computes and which
already understands slabs and stacked levels.

What follows:

- a prop cannot straddle a cell it does not occupy;
- raising the ground raises what stands on it, because the transform is a
  consequence rather than a copy;
- the editor places a prefab by clicking a cell, which is the gesture the block
  tools already have and the prop tools do not;
- depth comes free, because Z is derived along with X and Y.

Occupancy then stops being derived from a transform that was itself derived
from nothing.

The offset inside a cell is deliberate. Snapping every prop to a cell centre
makes a grid look like a spreadsheet; an offset lets a scene nudge a rock
off-centre without leaving the cell that owns it, and without reintroducing a
position nobody can trace back to a cell.

### The tile volume stops being special

A volume's cells take their Z from grid depth by the same rule, so `layer_step`
— invented because a volume had nowhere else to go — is deleted rather than
generalised. A block and a prop then sort against each other by position,
because they are finally measured on the same axis.

### Ground is not a wall

Depth alone put the ground in front of the player standing on it. The column
ahead of a walker is nearer than the one under their feet, so a flat top face at
exactly the height they stand at won on depth and drew over their legs.

No single depth fixes it. In an isometric projection a walker on a cell of depth
`d` has ground a step ahead at `d + 1` and a wall a step ahead at `d + 1` too:
the first has to lose to them and the second has to beat them, and one number
cannot do both. Giving top faces and side faces different offsets only moves the
contradiction — a cell's wall then sorts a full cell ahead of its own top, and
the wall of a block paints over the ground in front of it.

So the rule is not about the ground. It is about what stands on it: **a cell
whose surface is no higher than your feet has no face that can cover you.** Not
its top, and not the walls holding that top up. A walker therefore sorts one
cell forward, past the ground it is walking into, and terrain keeps the depth it
always had — cells still sort against each other by the column they stand in,
and every ordering already established here is untouched.

Only the cells a step ahead are consulted. A diagonal neighbour is two steps of
depth away and its top never rises far enough to reach the sprite. A step ahead
that *is* higher takes the forward sort back, because then it is a wall and
covering the walker is its job.

One case has no answer: standing in the corner between open ground one step
ahead and a raised block one step ahead. The ground wants the walker moved
forward and the wall wants it left alone, and a single depth answers for one of
them. It answers for the wall, because a walker drawn through a wall is worse
than a seam against the ground. `docs/parity.md` carries the row.

### What a placement covers

A placement names a grid the way an occupant does — the same stable scene ID,
now the same type — and says which cells it covers relative to the one it names.
One cell is the default and the common case.

What reads the footprint today is a refusal. Every cell a placement covers has
to hold it up, and one that does not is an error naming that cell. Before this,
a prop over a hole was quietly placed at height zero: three rocks standing off
Gather's shore were left on nothing, and the fix was to author a sandbar under
them rather than to be told. The check is whole-footprint rather than
anchor-only, because a house whose anchor is on good ground and whose far corner
is over water is exactly the case an anchor-only check waves through.

Without a tile set there is nothing to ask, and a placement still lands on the
grid's own plane, which is the same refusal `WorldGridNavigation` makes for the
same reason.

The footprint is also what ordering will need when draw order stops coming from
one point per entity. It is authored here first because placement is where an
author already says where a thing is.

### Standing everywhere

A rule can be right everywhere anybody thought to look and wrong on the open
ground where the player walks, which is how the ground came to be drawn over
them on a phone rather than in a test.

`sweep_occlusion` puts a virtual actor on every column of a volume that has
ground in it and asks the two questions the renderer asks — `face_depth` for a
cell and `standing_depth` for the actor, the functions themselves rather than a
restatement of them — then reports every cell that cannot cover the actor and is
drawn after it regardless. Each finding carries the raised step that took the
actor's clearance away, so the known corner is separated from a fault nothing
accounts for; `OcclusionReport::unexplained` is the one a test asserts on.

The probe is a size and an anchor rather than a sprite, because how far an actor
reaches decides which cells can reach it back. A sweep run with the wrong actor
proves something about a different game.

The sweep is a property of a grid and a volume rather than of any game: it takes
a world, whichever projection the grid is in, and an actor's bounds. What it
does not cover is a scene with no volume in it — a game whose props are sprites
on no grid has nothing to stand on and gets an empty report rather than a
guarantee.

Gather is where it is exercised, as the capability rule requires: 387 standable
columns, the corner reached in 22 of them, all of them a walker beside the
raised edge of a plot. That count lives in the game's own test, not in the
engine.

### Seeing it

The editor draws the report on the grid it is about, under Build → Ordering: the
ground stood on is filled, the cell covering it outlined, and a line joins them.
On the grid rather than in the view's chrome, because a fault is a fact about two
cells and projects through the volume's own transform, so moving the grid moves
the marks. Amber is the wall corner; red is a fault nothing accounts for.

A list of coordinates is not worth reading. Seeing that all of them lie along
one edge of one plot is what says there is a single cause.

### What one point per sprite cannot do

A tall sprite has one ground point and therefore one depth. A tree whose trunk
belongs behind the block in front of it, and whose crown belongs in front of the
block behind it, cannot be expressed by one number, and this note does not
pretend otherwise.

Three ways out exist, none chosen here: splitting the art into bands at bake
time, a per-pixel depth channel, or a declared height that sorts a sprite's
upper band separately from its base. Every 2D engine lives with the
single-point rule and accepts the artefact at the margins.

What this fixes is the ordering that is wrong *today*, where the failure is not
a tall tree at a boundary but a waystone drawn behind the ground it stands on.
Those are different sizes of problem, and only one of them is worth a per-pixel
depth buffer.

### Migration boundary

`layer` keeps working, keeps its meaning, and keeps winning. Depth orders
*within* a layer, so every scene in the repository draws as it does today until
its own props move onto the grid.

Gather's seventy-four integers are gone, and their deletion is the proof: a
scene that renders with every authored layer removed has demonstrated that
derived depth reproduces what the hand computed. `layer_step` went with them.
Two Decay scripts that computed the same formula at runtime — the player's and
the wisp's — lost those lines too, which is the same statement made in the other
place it was being made.

Batching improved as a side effect. Gather drew 104 sprite batches with
forty-five distinct layers and draws 85 with one, because a layer boundary is
also a batch boundary and most of Gather's were describing depth rather than
grouping.

The other games do not move. Orbital's ten layers are groups and are already
right.

### What this does not decide

Named so nobody reads this note as covering them. Each is real 2D work, and each
is easier once depth and placement are derived rather than authored:

- 2D lighting, normal-mapped sprites, and coloured light on a tile volume;
- drop shadows that follow the surface a thing stands on;
- cutaway or fade when terrain stands between the camera and the player;
- slice views that hide levels above a chosen one;
- auto-tiling and terrain transitions, which belong to Tile System 2's terrain
  rules;
- pixel-perfect camera snapping and integer-scale presentation, which
  `docs/2d-model.md` already constrains to an orthographic camera.

Parallax is deliberately absent from that list. It is not further work; it is
what a perspective camera does once cells have real depth.

### Acceptance test

In the Sindri editor, starting from an empty scene:

1. create one isometric grid and paint ground across it;
2. place a prefab by clicking a cell, typing no transform;
3. raise the column beneath it and see the prop rise with the ground;
4. walk a character around it — in front, behind, and to either side — with
   stable, correct order and no layer authored anywhere in the scene;
5. switch the grid to orthogonal and see the ordering stay correct;
6. stack a block beside the prop and see the two sort against each other by
   position rather than by an integer;
7. delete every `layer` in the scene and see the render not change;
8. swap the orthographic camera for a perspective one and see parallax, with no
   content edited;
9. save, reopen, and reproduce the same scene and the same frame.

Passing requires no authored layer numbers, no typed transform for a
grid-placed prop, and no draw-order repair.

Step 7 is the one that matters. The others can be satisfied by a careful author;
only that one can be satisfied by the engine.

---

Source: `docs/cameras.md`

## Camera semantics

Sindri has two different camera concepts and deliberately keeps them separate.

### Authored cameras

A `sindri.camera` component is a world/game camera. It is an ordinary scene entity and gets its pose from the entity's `Transform3D`:

- `Transform3D.position` is the camera position.
- `Transform3D.rotation` is the camera orientation.
- local `-Z` is forward.
- local `+Y` is up.
- scale is still part of the normal transform, but does not change projection.

Decay can change an explicitly selected orthographic camera’s view size with `Camera.orthographic_size(camera, size)`; see the runtime camera contract (in `03-scripting-guide.md`). Transform scale still does not zoom a camera.

Perspective and orthographic are projection choices for the same world-camera concept. Perspective projection uses vertical FOV, near and far planes. Orthographic projection uses vertical size, near and far planes. Orthographic cameras are not a special UI or overlay role.

The current renderer supports exactly one authored world camera for a game frame. A scene with world-rendered content and no authored camera reports `MissingWorldCamera`. A scene with more than one authored world camera reports `MultipleWorldCameras`. Sindri does not choose a winner from entity iteration order.

Supporting camera stacks, render targets, split-screen, or explicit camera priority later should be an authored feature with its own ordering/composition contract. It must not be introduced by making entity order significant.

### Gameplay camera behavior

Projection and gameplay behavior are separate. A camera may carry
`sindri.camera.behavior` beside `sindri.camera` to follow a target, stay inside
world bounds, and respond to impact shake without making those policies part of
the renderer.

Follow has an offset, a rectangular dead zone, exponential smoothing, and an
optional maximum speed. Confinement clamps the resulting XY camera position to
a world-space rectangle. Shake is transient trauma: amplitude is quadratic in
trauma, its phase is deterministic across native and web, and trauma decays each
update. Behavior order is follow, then confinement, then shake, so impact motion
may briefly cross a confine edge without changing the camera's underlying
follow position.

An orthographic 2D camera can set `confine.contain_view` to `true` to interpret
`min` and `max` as the edges of the level rather than limits for the camera's
center. Each gameplay step insets those edges by half of the view at the
current viewport aspect. When a view is larger than the level on an axis, the
camera centers on that axis. Lamplighter uses this for portrait and landscape.
When following, the camera keeps its target inside a small margin of the view
even if resizing would otherwise leave the target offscreen, subject to the
level bounds.
This mode is for unrotated, unscaled 2D cameras whose scene root has no transform;
camera shake can still move briefly outside the bounds. Callers outside the
shared session must pass their viewport to `update_camera_behaviors_with_viewport`.

An optional `follow.look_ahead` with a `fraction` from zero to 0.8 offsets the
camera horizontally by that fraction of the view's half-width in portrait.
It tracks target movement, holds direction while stationary, and requires 0.5
world units of reverse travel to change an established direction. The offset
eases toward its new value; in landscape it eases back to zero. Confinement
still wins at level edges. Lamplighter opts in to show Wick more of the path.

The behavior update is an engine operation rather than a render-extraction side
effect. A host advances it once per gameplay frame with
`update_camera_behaviors`; extracting the same world twice never advances or
changes camera state.

Every host that runs gameplay advances it after the scripts, so the camera
follows where that step left its target: the editor's Play, the browser host
that runs exported games, the camera example and the platformer's test harness.

#### Camera Lab acceptance surface

The dedicated demo lives in `examples/camera`, now an authored project with
a `sindri.toml`, packaged font and responsive Weave presentation, exported at
`/examples/camera/` and listed in the Pages directory. Natively,
`sindri-player examples/camera` plays the same project, stylesheet and all;
the lab's own window app, which skipped its Weave styles, is gone, and its
crate is the harness its tests play it through.

The cyan camera-relative rectangle visualizes the dead zone; amber boundary
lines and corner markers visualize camera-center confinement. The outer gray
frame marks the target's movement area. Follow, confinement, shake, smoothing
and dead zone have independent on-screen and keyboard controls. A touch movement
pad, impact button, automatic tour and reset make the behavior explorable
without a keyboard. The interface shows settings and sampled coordinates.

Decay changes the real behavior through the existing runtime Camera API
(runtime control contract (in `03-scripting-guide.md`)); it does not implement
a second follow or shake algorithm. Rust advances scripts and then
`update_camera_behaviors`. Turning follow back on restores the demo's selected
dead zone, smoothing and speed cap. Reset restores modes and target position,
then lets normal follow settle the view rather than overwriting transient shake.

Native regressions exercise follow, confinement, independent modes, restoring
settings, trauma decay, phone touch input and the tour. Browser smoke exercises
the visible controls and captures desktop and phone output, with both Pages
project-subpath and custom-domain exports.

Runtime mode changes are already available through `Camera.follow`,
`clear_follow`, `follow_offset`, `dead_zone`, `smoothing`, `max_speed`,
`bounds`, `clear_bounds` and `shake`. Orbital uses `Camera.impact` for
combat shake. Dedicated editor behavior controls and gizmos remain follow-up
work.

### Third-person 3D orbit

`sindri.camera.orbit` follows an authored target in world space, smooths its
position, faces the focus and pulls in immediately when a 3D sight-line ray
hits an obstruction. Yaw/pitch are radians, with zero yaw on +Z and positive
pitch above the target. Settings and limits are in
`camera-runtime-scripting.md` (in `03-scripting-guide.md`). The shared
runtime steps it after scripts and animation using synchronized physics poses.
It takes precedence over 2D behavior on the same camera.

The registered component describes its target as an entity field and exposes
numeric settings to the generic inspector. Add Component now completes Camera
Orbit from a distinct active spatial entity with a stable ID, excluding camera
children and viewport entities. It requires a Camera on the owner. Inspector
edits validate settings and target ownership before becoming commands; rejected
orbit edits retain the previous payload while unrelated valid component edits
remain independent. Creation, numeric editing, Undo/Redo, save/reopen and authored
Play are exercised in the native review (`docs/camera-orbit-editor-review.md` in the Sindri repository).

Selecting an active orbit camera shows an amber focus marker, requested
position, sight line and yaw circle at the authored pitch/distance. A blue
"Camera now" marker and line show the actual world pose when it differs by more
than 0.05 units, including smoothing and collision pull-in during Play. Offset
and angles follow world axes even with rotated/scaled parents. Lines use the
same near-plane clipping as frusta; guides remain visible when the camera
marker is behind the Scene eye and never become picking targets or commands.
These are settings/pose guides, without an editor-side obstruction prediction
or camera-volume clearance visualization. Explorer gameplay proof remains
pending. The Orbit Camera Lab (`examples/orbit/README.md` in the Sindri repository) exercises it in a real project;
Explorer remains the game proof.

### Screen-space UI and overlays

The `sindri.ui.*` family does not require a camera entity. Their projection is owned by the viewport and their anchors resolve against the viewport's screen-space extent.

This is intentionally similar in principle to a screen-space-overlay UI model: UI can exist without a second scene camera. An orthographic projection matrix may still be used internally to turn screen coordinates into clip space; that projection math is not an authored camera.

### Editor Scene camera

The editor Scene view has its own editor camera. It is not stored as a `sindri.camera` component and does not modify authored camera transforms or projection settings.

The Scene camera is used consistently for Scene rendering, picking, gizmos, tile painting, focus, and the axis indicator. The Game view continues to render through the authored world camera.

**UI is the exception, and it is not an exception to the rule so much as an application of it.** A UI element is laid out against the viewport rather than in the world: an anchor picks a point and the element's transform is an offset from it, so no camera — Scene or authored — has anything to say about where it is. Picking one and drawing its gizmo therefore go through the overlay, handed out by `overlay_for_viewport`, which needs nothing but the viewport's aspect ratio. The rule is the same one as everywhere else here: whatever put the picture on the screen is what a pointer has to be inverted through, and a matrix rebuilt somewhere else is a second answer that only has to disagree once.

Authored cameras are visible and selectable objects in Scene view, including their direction and projection volume, but those editor-only visuals never appear in Game/runtime rendering.

Two rules keep that picture honest. **A frustum is drawn at the aspect the camera renders at**, which is the Game viewport's and not the Scene view's — otherwise dragging the divider beside the Scene view reshapes a frustum that has not changed, which is a picture claiming the camera frames more of the world because a panel got wider. And **every line is clipped against the near plane in clip space, before the perspective divide**: a point behind the eye has no projection, and dividing by its negative `w` puts it somewhere plausible on the opposite side of the screen, which is what made a frustum smear across the viewport while orbiting past it.

The projection volume is drawn for the **selected** camera only. An unselected one is its marker and a short forward stub, because a frustum reaches to the far plane and several of them crossing the viewport say nothing about the one being worked on. The marker and the stub are also the whole of what a click selects: a far-plane edge a hundred units away is not what someone means when they click on it.

### Scene format 7

Format 6 used orthographic `sindri.camera` components for the old screen-overlay implementation. Format 7 removes that role.

The built-in 6 -> 7 migration therefore removes format-6 orthographic camera components while preserving their entities and unrelated data. Perspective cameras survive unchanged. From format 7 onward, an authored orthographic camera is a normal transform-driven world camera.

The earlier 5 -> 6 migration remains part of the chain: old perspective `target`/`up` look-at data is converted to the equivalent `Transform3D.rotation`. `target` and `up` are not part of the current camera model and must not be reintroduced.

### Causeway quarter views

Causeway's `CameraRig` uses the existing world-space `rotate_around` API to
turn around (28, 1.4, 28) in four 90-degree steps with 300 ms smoothstep easing.
The authored pitch, distance, focus, orthographic size and aspect fitting are
preserved. `TitleShow` supplies the authored pose and explicitly hands ownership
to the battle rig after the opening. Impact shake remains an engine behavior.

J/L and the compact HUD arrows rotate on desktop or touch. A single-finger
horizontal swipe of at least 48 pixels can also rotate when it begins outside
the board or over an empty wet cell. HUD and playable-cell starts retain their
original meaning; multi-touch cancels the swipe. One gesture requests one turn.
Picking and game commands wait until the new view's ray has settled, and a
pointer held across a turn must be released before it can act. Keyboard cursor
movement follows the current quarter view; movement and combat remain in board
coordinates. Model silhouettes (`docs/model-occlusion.md` in the Sindri repository) retain hidden-unit visibility.

T and the HUD's Top button tip the rig into a floor plan and back: after the
quarter turn, a second `rotate_around` about the camera's own right axis by the
authored pitch's distance from straight down, and a turn about Y that squares
the grid with the screen so the cursor's up key goes up it. Both angles are read
from the authored pose when `TitleShow` hands it over, and turning and tipping
share one eased animation, so picking waits for either to settle.

---

Source: `docs/grid.md`

## Grid coordinates and projection

`sindri-grid` is the renderer-independent geometry shared by sprite-isometric
and orthographic-3D games. It owns logical cells, finite bounds, neighbour
queries, and the reversible projection of a logical grid onto a two-dimensional
plane. It does not know about worlds, cameras, sprites, tilemaps, editors, or
Decay.

That boundary is the feature. Gameplay should be able to ask which cells are
adjacent or occupied without linking a renderer, and a renderer should not be
the authority on where gameplay believes a character stands.

### Coordinate convention

`GridCoord { x, y }` names an integer cell. The integer coordinate is the
**centre** of the cell; half-integers are its boundaries. `GridPoint` carries a
continuous position in the same axes.

`GridSpace` places that logical grid on a plane:

- orthogonal: `plane = (x * width, y * height)`
- isometric: `plane = ((x - y) * width / 2, (x + y) * height / 2)`

Both include a configurable plane origin and an explicit plane-Y direction:
screen-like planes grow down, while Sindri world XY grows up. `unproject` is the algebraic inverse,
and `plane_to_grid` assigns the continuous result to a half-open cell: a shared
boundary belongs to the cell in the positive grid direction. Tests cover every
cell in a 257×257 region on both projections, negative coordinates, fractional
positions, non-square cells, and a non-zero origin.

The projected plane is intentionally not named world or screen space. A 2D
sprite game may map it to world XY, an orthographic 3D game may map it to world
XZ, and a UI may map it to pixels. `PlaneYAxis` handles the first distinction;
camera projection, pan, zoom, and viewport centring remain presentation
adapters. Keeping those outside this crate is what lets the gameplay grid be
shared.

### What came from the earlier projects

The legacy Sindri engine's `Grid<T>` demonstrated the useful small core: signed
cell coordinates, rectangular bounds, deterministic row-major traversal, and
four/eight-way neighbour queries. It also tied coordinate conversion to its
render `Vec2`, stored `cell_size` beside cell data, and used unchecked casts and
allocation. Sindri Engine keeps the concepts, not that coupling or implementation.

IsoGame's `CoordinateUtils` contains the standard reversible diamond formulas
used above. It also combines them with room dimensions, canvas dimensions, pan,
zoom, and centring. Those are presentation transforms and become editor/camera
adapters later; putting them in the grid would make headless pathfinding depend
on a viewport that may not exist.

IsoGame's pathfinder allowed diagonal movement with an approximate `1.4` cost
while using a Manhattan heuristic, scanned a set for the lowest score, and
mixed actor exclusion into its passability callback. Sindri's port keeps the
useful A* behaviour but replaces those details with explicit topology and
integer costs, an admissible policy-specific heuristic, and application-owned
passability.

`sindri.tilemap` is the first engine adapter. It keeps projection and tile size
in the serialized component, then exposes the `GridSpace` and `GridBounds` that
rendering and editor picking use. The adapter flips the neutral grid into
Sindri's upward world Y, and extraction composes every cell with the map's full
transform so a rotated or scaled map draws where picking says it is.

### Current and next

This first slice provides:

- `GridCoord`, continuous `GridPoint`, and `PlanePoint`
- finite `GridBounds` and stable bounded/unbounded neighbour iteration
- validated orthogonal and isometric `GridSpace`, with upward/downward plane Y
- reversible continuous projection and cell lookup
- a tilemap adapter shared by frame extraction and editor picking
- explicit errors for invalid sizes, non-finite points, overflow, and values
  outside the integer coordinate domain
- normalized arbitrary and rectangular `GridFootprint` values
- bounded `GridOccupancy<Owner>` with queries, removal, dry-run validation, and
  atomic placement or movement
- explicit placement failures for conflicting occupants, out-of-bounds cells,
  and coordinate overflow
- authored `sindri.grid.navigation` wall edges and `sindri.grid.occupant`
  stable grid references plus relative footprints
- `WorldGridNavigation`, which derives runtime occupancy from world transforms
  and exposes complete-footprint placement and path queries

Decay now exposes continuous logical position through an explicit tilemap
entity: `Grid.position_x`, `Grid.position_y`, and `Grid.place` invert and apply
the same projection plus the tilemap's full world-XY transform. Gather uses that
surface for movement, floor bounds, and collection distance without moving any
game rule into Rust.

Occupancy deliberately stores application-owned identifiers rather than engine
entities. The core can therefore back a headless simulation, an editor preview,
or a world adapter without acquiring an ECS dependency. A move may overlap the
owner's previous cells, but the entire destination is validated before those
old cells are released, so a failed move cannot leave a partial placement.

The renderer-free A* layer now builds directly on that definition of
passability. `GridPathfinder` supports cardinal or eight-way movement, explicit
integer cardinal/diagonal costs, and an explicit corner-cutting policy. Its
Manhattan or octile heuristic remains admissible even when a configured
diagonal costs more than two cardinal steps, and stable neighbour/insertion
ordering makes equal-cost results deterministic.

A caller may supply any cell-passability function; each answer is memoized for
the duration of the search. `GridOccupancy::find_path` supplies the standard
adapter by validating the moving owner's complete footprint at every candidate
anchor while allowing overlap with that owner's current cells. A returned
`GridPath` includes both endpoints and its total cost; an impassable or
disconnected goal is `None`, while invalid endpoints and arithmetic exhaustion
are explicit errors.

Wall edges are a separate primitive rather than hidden in passability.
`GridWallEdge` accepts only cardinal neighbours and normalizes their order, so a
wall has one symmetric identity regardless of which cell asks. `GridWalls`
bounds those edges, makes block/unblock idempotent, and iterates them in stable
coordinate order. Grid bounds already prevent leaving the map, so this set
represents internal walls rather than duplicating the perimeter.

`GridPathfinder::find_path_with_walls` checks a cardinal move against its one
shared edge. A diagonal is allowed only when all four cardinal edges around its
corner are open; the separate corner-cutting policy still decides whether the
two side cells themselves must be passable. Occupancy can combine both rules
through `GridOccupancy::find_path_with_walls`, validating the complete moving
footprint at each anchor and checking the wall crossed by every constituent
footprint cell during each transition.
Mismatched wall/path bounds are rejected explicitly.

The engine adapter deliberately does not serialize occupancy. A
`sindri.grid.occupant` names an authored tilemap by `SceneEntityId`, carries
only its relative footprint, and takes its anchor from the entity's current
world position. `WorldGridNavigation` resolves those stable references into
runtime `EntityId` owners, inverts the tilemap's planar transform and exact
`GridSpace`, builds all walls and placements, and rejects the whole snapshot if
any wall, footprint, or placement is invalid. Rebuilding after a world change
keeps the world authoritative and avoids stale duplicated cell state.

The tilemap remains the sole owner of bounds and projection. The optional
`sindri.grid.navigation` component stores only internal wall endpoints, so a
scene cannot configure one shape for rendering and another for pathfinding.
The adapter targets one explicit tilemap entity, preserving the same
multi-grid rule as Decay's coordinate surface.

Editor authoring, typed Decay access, overlays, and a Gather pathfinding use
case remain cross-layer adapters tracked in the integration matrix.

---

Source: `docs/tile-system-2.md`

## Tile System 2

Status: block-volume foundation implemented; terrain rules, collision, and
navigation remain in progress.

Gather reached the limit of `sindri.tilemap`. The grid arithmetic held up; the
representation around it did not. One component currently owns logical cells,
projection, sprite selection, visual bounds, terrain thickness, draw order, and
the editor palette. `tile_overhang` is the clearest symptom: a visual size is
being asked to mean terrain elevation.

Tile System 2 keeps `sindri-grid` and replaces that mixed representation.

### Invariants

1. A cell coordinate is logical data. It does not move when art changes.
2. Elevation is an integer Z coordinate. It is never inferred from PNG height.
   A tile may fill part of its cell, and says so as a number rather than by
   being drawn shorter; nothing occupies more than the cell it is in.
3. A visual has an explicit ground anchor and may overflow its cell without
   changing collision, navigation, picking, or elevation.
4. Painter order is global across textures. Batching may combine adjacent draws
   only after ordering has been decided.
5. Terrain meaning is independent of the sprite variant chosen to show it.
6. Orthogonal and isometric maps share storage, terrain, collision, navigation,
   and authoring. Projection changes coordinate conversion and presentation.
7. Editor painting writes terrain and elevation, not edge-frame names.

### Four levels

#### Grid

Pure geometry and bounds:

- projection: `orthogonal` or `isometric`
- columns and rows
- logical cell size
- grid-to-plane and plane-to-grid conversion

The existing `sindri-grid` types remain the authority for this math.

#### Tile volume

Sparse 3D cell storage attached to a grid:

- tile ID at an integer `(column, row, level)` coordinate
- optional deliberate visual override
- volume-wide visibility and ordering policy

Empty is absence, not a reserved tile ID. The serialized form stays a readable
sparse cell list. Runtime code shares a 16×16 `TileChunkCoord` and
`TileChunkStore`, so generators and render culling use the same seams while a
scene or save needs no second chunk-shaped file format. Causeway used them to
stream its terrain until it moved its ground onto the engine's voxel world
(`sindri.voxel_world`), which reads the same tile sets and owns residency,
edits and meshing itself.

A surface is a derived view of a volume, not its storage model. Terrain tools
usually edit the highest occupied level in a column and may fill supporting
strata automatically. A block tool edits one exact 3D cell. Both therefore
produce the same data, and stacked blocks, caves, bridges, walls, and overhangs
need no second map system.

#### Tile set and terrain rules

Project asset describing semantic terrain:

- stable tile IDs such as `grass`, `earth`, `water`, `soil`, and `stone`
- how much of its cell each tile fills: a `height` from the floor upward, or an
  `extent` box naming the part of the cell the tile occupies as fractions of it,
  `[across, up, into]`. A height is the full footprint by definition, so
  everything it can describe is a slab of some thickness — a post, a fence, a
  kerb, a rail and a step are all outside what it can say, and a rail is outside
  it twice over because a height always starts at the floor. A document naming
  both is refused rather than having one of them quietly win
- whether a tile's top holds anything up, and separately whether a walker can
  stand on it — water does the first and not the second, and one flag saying
  "solid" could not tell a pond from a hole
- face visuals for top, bottom, and four horizontal neighbours
- `tags`: words a game gives a tile (`hot`, `slippery`) that the engine never
  reads and scripts ask about with `Grid.tagged`
- a face's `animation` (further frames on the same texture, and a speed) and a
  tile's `glow`; voxel worlds draw both, tile volumes do not yet
- adjacency groups and allowed transitions
- deterministic weighted variants: one tile ID with several looks, chosen from
  a hash of the cell, the tile's name and the volume's seed. The scene stores
  what a cell *is* and the renderer decides what it looks like, so variation
  costs nothing per cell and is the same on every machine and after every
  reload — which a render capture depends on. A volume's `variant_seed` rerolls
  the whole thing at once. The hash is written in-repo rather than taken from
  `std`, whose default hasher may change between releases and would rearrange
  every scene that used it

A box changes what covering means. A height could only ask whether a neighbour
was tall enough, which stops being the question the moment a tile does not fill
its footprint: a post against a wall is tall, and hides almost none of it.
Culling now asks whether the two faces meet at the wall between the cells at
all, and whether the neighbour covers the whole of the face rather than part of
it. Corner darkening asks the narrower question still — only a neighbour filling
its cell crowds a corner — because a fence post is not a wall and should not
shade like one.

A box is the shape the tile *is*, not the shape of its art. Its faces are still
six quads placed on the box's own sides, so a fence post is a thin box wearing a
fence texture rather than a full block whose picture happens to be mostly
transparent. Picking is unchanged and still treats a cell as a whole cell, so a
click near a post's cell finds the post.

Where one terrain gives way to another, the threshold that decides it is frayed
rather than sharp: near the crossing the answer comes from a field sampled at a
few cells' scale rather than from the comparison alone. A threshold on a smooth
field draws a smooth line, and a smooth line through a landscape reads as a
border on a map rather than as ground changing. Deciding each column on its own
would fray it too, but into salt and pepper — single cells of snow scattered
through grass read as dirt on the screen, not as snow lying in the hollows.

Tile sets are assets rather than components so several scenes and volumes can
share one definition without copying it into every scene. A voxel world names
one as its block set, so the same blocks build a tile volume and a voxel world;
the engine ships `builtin:blocks`, and the editor edits any tile set as a grid
of cubes (see `voxel-terrain.md` (`docs/voxel-terrain.md` in the Sindri repository)). Optional terrain
rules describe high-level surface painting: the surface tile, supporting fill,
transitions, and which tiles count as the same terrain family.

#### Tile visual

One representation a tile or terrain rule may choose:

- texture and named sheet sprite
- role: top, bottom, north, east, south, west, corner, transition, or decoration
- explicit ground anchor
- visual bounds/overflow
- repeat or stretch policy for an exposed vertical span

A visual says how it is drawn, never how high the cell is.

### Derived rendering

For each occupied 3D cell, the renderer:

1. resolves its tile definition;
2. culls every face whose neighbouring 3D cell occludes it;
3. chooses deterministic face variants from the tile and its neighbours;
4. emits the remaining top, side, transition, and decoration visuals;
5. assigns deterministic painter keys from volume, projected cell depth, level,
   face role, and stable cell coordinate;
6. globally sorts emitted visuals and only then groups adjacent equal textures
   into draw calls.

Depth belongs to the *column*, not to the drawn face. Every face of a cell takes
the depth of the point where its column meets the ground, so raising a block
moves it up the screen without moving it toward the viewer, and a cell's own
faces are ordered by face role rather than sorted against each other. What
decides between draws at equal depth — the levels of one column, and everything
in a view laid out along the camera's depth axis — is the painter key, whose
leading term is the projection's own idea of distance: `x + y` along an
isometric diagonal, `y` alone down orthogonal rows.

An isometric column two blocks taller than its neighbour therefore exposes two
side faces naturally. Removing the lower block creates a real opening rather
than a special cliff case. No sprite is stretched into a slab and no overhang
value is consulted.

Projection supplies three basis vectors, and also decides which faces a view can
see at all: an isometric view is turned between two axes and shows a cell's top
and two sides, while an orthogonal view looks straight down the rows and shows
the top and the side facing the viewer — its east and west faces are edge-on and
have no width to draw. Neither shows an underside. Asking the projection rather
than drawing whatever a tile set happens to define is what lets one set of tiles
serve both, instead of painting an isometric side flat across an orthogonal
block.

Orthogonal and isometric views differ
in the screen-space X/Y basis; both give integer Z a configured vertical step.
This remains sprite rendering: a block is resolved into visible 2D face visuals,
not submitted as runtime 3D geometry.

### Authoring

The editor's first complete block workflow is now available on an entity that
carries both `sindri.tile_grid` and `sindri.tile_volume`. Its Build inspector
loads the volume's validated tile set and offers two undoable tools:

- **Surface** picks the frontmost exposed top diamond. Place stacks one block
  above it; Remove deletes the block that produced it. Empty space starts a
  foundation at level zero. Surface gestures are click-only so holding a drag
  cannot accidentally grow or drill an entire column.
- **Level** addresses one explicit integer Z level and supports click-drag
  painting for floors, shelves, and deliberate overhangs.

Both modes preview the exact target cell in the Scene view, keep cell ordering
deterministic, preserve unknown component fields, and commit through the
editor's command history so a stroke can be undone and saved normally. Tile-set
choices are cached for the selected asset rather than reread every frame.

This is intentionally the block half of the authoring contract. Side-face
picking, terrain-name painting, automatic supporting strata, slice/isolation
views, and generated collision/navigation are still later Tile System 2 work.

The primary terrain tool paints a terrain ID on a column. A height gesture
raises or lowers its surface and the terrain rule fills or removes supporting
tiles. A block tool and level/slice control edit one exact `(x, y, z)` cell for
construction, caves, and overhangs. Neighbour rules update the preview without
rewriting cells into edge variants. An advanced override may pin a visual
variant, and clearing it returns the cell to automatic resolution.

Block authoring follows a "LEGO, not CAD" rule. The implemented top-face tool
places the selected block above that face and removal targets its source cell;
the explicit-level tool paints snapped planes. Extending that same contract to
side faces will place blocks in their horizontal adjacent cells. Slice and
isolation views will reveal hidden levels without making authors type
coordinates. Every gesture previews the same derived baked faces the running
game draws.

Picking tests emitted faces front-to-back and returns the 3D cell and face that
caused the hit. Selection may inspect a derived face, but edits the source cell.
Collision rebuilds from occupied cells and tile metadata.

Navigation walks surfaces rather than flattening the volume back into a 2D
obstacle map. A column's surface is the top of its highest solid cell, as a
fraction rather than a level so a slab is half a cell lower than a block, and a
column with nothing solid in it is a hole rather than ground at level zero. Each
edge between two columns is then a step a walker can take or a wall, which is a
thing `sindri-grid` already understands, so the volume contributes walls instead
of the pathfinder learning about levels.

What is authored is one symmetric `max_step`, not separate climb and drop
limits: grid walls block an edge rather than a direction, so a rule letting a
walker fall further than it can climb has nowhere to be recorded. Clearance and
overhangs are the same gap from the other side — only the highest solid cell of
a column is consulted, so a cave is a roof over nothing rather than a second
walkable level. Both wait on directional edges in `sindri-grid`.

### Migration boundary

`sindri.tilemap` remains readable only while repository scenes are migrated.
New authoring targets Tile System 2 and never writes `tile_overhang`.

Geometry is the one thing the two systems already share, and it is shared
deliberately rather than duplicated. Columns, rows, cell size and projection
mean the same thing in `sindri.tilemap` and `sindri.tile_grid`, and both put a
cell's origin in the same place and run their Y axis the same way, so anything
that only asks *where a cell is* takes either component: `Grid.position_x`,
`Grid.place`, `Grid.can_reach`, `Grid.step_toward`, `Grid.columns`, `Grid.rows`,
and the navigation snapshot behind walls, occupancy and pathfinding. An entity
carrying both — a migration in progress — answers from the flat map, so adding
a volume beside an existing floor changes nothing until the flat map is removed.

What is *not* shared is what a cell holds. `Grid.tile` and `Grid.set_tile` index
a palette in a flat array, which is the old model rather than a translation of
it, and they refuse on a grid that carries only `sindri.tile_grid` instead of
pretending a volume has one level and a palette. A flat old
map can be translated mechanically into a grid, one tile volume at `z = 0`, and
an imported tile set. A slab map cannot be translated honestly because sprite
overhang does not state elevation; migration must require an explicit level and
fill choice.

Gather has now moved. Its island is 625 blocks at `z = 0` with the outcrop
stacked on top, the shed is 35 more, and no scene in the repository carries
`sindri.tilemap`. The translation was the mechanical one this section describes:
each palette entry became a tile of the same name — all five kinds of grass, so
the floor kept the variation the scene had already authored — and every filled
cell became a block at level zero.

Two things the move surfaced that a flat map had made unaskable. Water is opaque
but not walkable, so the island is bounded by the shape of its own ground
rather than by an authored wall.

That began as one flag. Water was "not solid", which answered the navigation
question correctly and took the moat's surface away with it, so three rocks
standing off the shore were left on nothing and had to be given a sandbar. A
pond holds a boat up and a hole does not, and `supports` and `walkable` are now
separate because no single flag can say which of those a cell is. Gather's
water supports and is unwalkable: 625 columns of the island hold something up,
387 of them can be stood on, and the 238 between them are the moat and the
pond. Navigation reads the second number and is unchanged. And a
volume in a 2D scene needs somewhere to be in the draw order, which is what
`layer_step` is: viewed straight on there is no depth but the render layer, so a
volume that interleaves with sprites places its cells along it.

`sindri.tilemap`, its editor palette, and `tile_overhang` can now be removed
together whenever that change is worth making on its own; nothing shipped here
depends on them. The new format will not carry a compatibility field that
preserves the ambiguity.

### Acceptance test

In the Sindri editor, starting from an empty scene:

1. create one isometric grid;
2. paint grass, water, soil, and paths by terrain name;
3. raise and lower arbitrary columns through the terrain tool;
4. stack individual blocks into a wall and bridge, then cut an opening beneath;
5. see correct tops, transitions, and occluded/exposed faces update automatically;
6. place a character and props with larger visual bounds;
7. walk above, below, in front of, and behind them with stable ordering;
8. pick any visible face and edit the exact 3D cell that produced it;
9. collide and pathfind with climb, drop, and clearance limits;
10. save, reopen, and reproduce the same scene and render.

Passing requires no manual edge selection, sprite offsets, overhang values, or
draw-order repair.

---

Source: `docs/terrains.md`

## Terrains

A terrain paints ground rather than tiles. The sheet names, once, which sprite
each pattern of neighbors draws: grass on an open top, a rim on an open side,
plain earth in the middle. A brush or a script then paints `ground`, and each
cell, and every neighbor whose picture depends on it, gets the right sprite.

### In the sheet

```json
"terrains": {
  "ground": {
    "kind": "edges",
    "sprites": ["ground-0", "ground-1", "...", "ground-15"]
  }
}
```

| Kind | Sprites | Indexed by |
| --- | --- | --- |
| `edges` | 16 | Which of the four sides are the same terrain: north 1, east 2, south 4, west 8 |
| `corners` | 16 | Which of the tile's four corners are the terrain: north-west 8, north-east 4, south-west 2, south-east 1 (below) |
| `blob` | 47 | All eight neighbors (north 1, north-east 2, east 4, south-east 8, south 16, south-west 32, west 64, north-west 128), a corner counting only when both sides beside it do; listed in ascending order of those masks |

`edges` is enough for blocky ground. `blob` tells an inside corner from a
filled one, for art whose corners show. A terrain that lists the wrong number of
sprites, or a sprite the sheet does not cut, is refused when the sheet loads.

A cell belongs to a terrain when its sprite is one of the terrain's. Past the
map's edge counts as more of the same, so ground painted to the edge of a map
carries on out of the picture rather than ending in a rim there.

### Corners

A `corners` terrain is the layout of a Wang or Tiled corner set: two terrains,
such as grass and dirt, and one tile for each of the sixteen ways the four
corners of a tile can be one or the other. Sprite 0, every corner the ground,
is the plain ground tile, and belongs to the terrain like the others, so a map
filled with it is a map of ground.

What a corners tile draws is decided at its corners, not by its neighbors, so
there is no cell to paint. A corner is a grid line's crossing: a map of 3 by 2
tiles has corners 0 to 3 across and 0 to 2 down. Setting one rewrites the up to
four tiles that meet there. Each reads the corners it already has from the
sprite it shows, adds or removes this one, and draws the sprite for the result,
so the map holds nothing but its tiles. An empty tile reads as having no
corners and a tile showing any other sprite is left alone.

| Call | Returns |
| --- | --- |
| `Grid.set_terrain_corner(map, column, row, terrain, set)` | nothing |

`set` is `true` to make the corner the terrain and `false` to give it back to
the ground. `Grid.set_terrain` on a corners terrain, and this call on any other
kind, are errors that name the terrain.

### Painting

The tilemap inspector lists the sheet's terrains above the palette, each shown
by its fully surrounded sprite. With one picked, the brush paints that terrain:
the cell is set and it and its eight neighbors are redrawn from the patterns
they now make. Erasing on a map whose sheet has terrains redraws the neighbors
too, so a hole dug in a hill leaves its edges showing.

Decay paints the same way:

| Call | Returns |
| --- | --- |
| `Grid.set_terrain(map, column, row, terrain)` | nothing |

`""` empties the cell. A terrain the map's sheet does not declare is an error
naming it.

### Limits

One terrain per cell, with no transitions between two terrains (grass into sand)
except through a `corners` terrain, whose two terrains are the ground and the
terrain, and no random variants of the same pattern. The editor brush does not
offer a corners terrain; paint it from Decay. The patterns are fixed: there is no
rule editor for arbitrary neighbor conditions.

### Proof

Lamplighter's ground is a `corners` terrain, the first grass-to-dirt tileset Pixel
Lab generated: `assets/scripts/ground.decay` paints its dirt path corner by corner
with `Grid.set_terrain_corner`, and `games/lamplighter/tests/the_ground_is_painted.rs`
reads the tiles back.

The platformer's level is painted with the `ground` terrain of
`textures/tiles.sheet`: grass grows on every open top and the earth shows a rim
at every gap. `games/platformer/tests/painted_ground.rs` repaints every ground
cell and requires that nothing changes.

---

Source: `docs/sprite-shaders.md`

## Sprite shaders

A project can draw any sprite through its own fragment shader: a `.wgsl` file
under the project's assets that defines one function. This is the contract for
what that function is handed, how a file becomes a shader on every host, and
what happens when it is wrong.

### What a project writes

```wgsl
fn sprite_shader(input: SpriteShaderInput) -> vec4<f32> {
    return vec4<f32>(mix(input.color.rgb, vec3<f32>(1.0), input.params.x), input.color.a);
}
```

That is the whole file. The engine places it between its own prelude and
epilogue (`crates/sindri-render/src/sprite_shader/`) and builds the sprite
pipeline around it, so the vertex stage, the bindings, instancing, depth and
blending are the engine's, exactly as for a sprite with no shader. A file may
define helpers of its own; names starting `sindri_` are the engine's.

`SpriteShaderInput` carries:

| Field | Meaning |
| --- | --- |
| `color` | The texture sample with tint and colour transform applied: the pixel as it would draw with no shader |
| `uv` | Where this pixel samples the texture, in normalized texture space |
| `local_uv` | Where it sits across the sprite, 0..1, whichever part of a sheet the sprite draws |
| `uv_rect` | The part of the texture the sprite draws: x, y, width, height |
| `world_position` | The pixel's world position |
| `params` | The sprite's four `shader_params` |
| `time` | Seconds the scene has played |

Two helpers reach the texture again: `sample_sprite(uv)`, which samples the
full-resolution level and so is safe inside a branch, and
`sprite_texel_size()`. A returned color's alpha blends as any sprite's does,
and the corner shading a voxel or tile face carries is applied afterwards.

### Authoring

`sindri.sprite` names the file in `shader` (an asset path, empty for the
built-in look) and carries `shader_params`, four numbers whose meaning is the
shader's. The inspector picks a shader from the project's `.wgsl` files and
edits the four numbers; Decay reads and writes
`this.sprite.shader_params.{x,y,z,w}` (see
scripting (in `03-scripting-guide.md`)). The parameters ride on each instance,
so sprites sharing a texture and a shader share a draw however differently
they are parameterised; a different shader is a different batch.

### Hosts

Every host checks a shader as it binds it, by parsing and validating the
composed module with naga, before any pipeline exists.

- The editor loads the shaders a scene and its prefabs name through the asset
  queue, watches them, and reloads on save.
- `sindri-player` reads them from the project directory natively and from the
  export manifest in the browser.
- `sindri-export` ships each shader a scene names and refuses to export one
  that does not compile, so a broken shader is found before a browser sees it.
- `project-capture` and `project-benchmark` bind them as the player does.

The renderer builds a shader's pipelines once per load and keys them by path;
a reload replaces them.

### Failure

A source that does not compile is reported with the file and the line in the
project's own numbering (`shaders/flash.wgsl:3: ...`). A file with no
`fn sprite_shader` says so. The sprite then draws with the built-in look rather
than the frame failing, and a reload that breaks a previously good shader drops
the old version too, so what is on screen never hides the break. A shader
parameter that is not finite is refused at extraction, as a colour transform's
is.

### Limits

One texture per sprite; a shader cannot bind more. There is no material asset:
the four parameters are the only per-sprite input. Shaders apply to
`sindri.sprite` only, not to tiles, shapes, text or UI images. Validation
places a type error on the line of the function it is in rather than on the
statement.

### Proof

`examples/sprite-shaders` draws an outline, a dissolve, a hit flash and a wave,
with Decay scripts driving the dissolve and the flash.
`game/tests/the_sprite_shader_demo_works.rs` compiles every shader it names and
plays the scripts; `crates/sindri-gpu/tests/sprite_shaders.rs` reads drawn
pixels back to show the shader, its per-instance parameters and the clock reach
the screen. Low Tide draws its submerged wrecks through
`shaders/underwater.wgsl`, bent and rippled with light by the brine above them.

---

Source: `docs/sprite-masks.md`

## Sprite masks

A sprite mask is a picture placed in the world whose shape decides where other
sprites draw. It is never drawn itself. A keyhole moving over a dark scene, a
spotlight, a window onto another layer, a health orb filling inside a round
frame: the shape is the mask's alpha, and each sprite says whether it shows
inside it or outside it.

### Authoring

`sindri.sprite_mask` sits on an entity and is placed by its transform the way a
sprite is, anchor included:

| Field | Meaning | Default |
| --- | --- | --- |
| `texture` | The picture whose alpha is the shape, as a sprite names one (`sheet.png#hole` works) | — |
| `name` | What masked sprites name to meet it; empty is a name too | `""` |

A `sindri.sprite` opts in:

| Field | Meaning | Default |
| --- | --- | --- |
| `mask` | `none`, `inside` (draws only where the mask is opaque) or `outside` (only where it is not) | `none` |
| `mask_name` | The `name` of the mask it meets | `""` |

Sprites find their mask by name rather than by being its children, because what
a keyhole reveals stays still while the keyhole moves. A sprite masked `inside`
a mask the scene does not have draws nothing; one masked `outside` it draws
whole. If two masks share a name, the first found is used.

Coverage is the mask's alpha, so a soft-edged mask gives a soft edge. Outside
the mask's quad counts as clear.

### How it draws

The mask is batch state, not per-sprite data: a batch carries one mask texture
and the matrix from the world into the mask's quad, and the fragment stage
multiplies each pixel's alpha by what the mask lets through. Sprites masked by
the same mask the same way still share a draw, and an unmasked sprite pays
nothing. Opaque (depth-writing) sprites discard what the mask hides.

There is no stencil buffer involved, so masks do not need a depth format with
stencil and nothing about masking reaches the 3D renderer.

### Limits

World sprites only: UI images, tilemaps, particles and shapes are not masked.
One mask per sprite, so masks cannot be combined into a union or intersection.

### Proof

`examples/sprite-masks`: a keyhole wanders over a night sky. The night is
masked outside it, so the sunny meadow under it shows through the hole, and a
gem is masked inside it, so it is only ever seen through the hole.
`game/tests/the_sprite_mask_demo_works.rs` plays it, and
`crates/sindri-gpu/tests/sprite_masks.rs` reads back pixels inside and outside a
mask.

---

Source: `docs/2d-lights.md`

## 2D lights and shadows

A 2D scene can be lit: the environment sets an ambient color, point lights in
the plane add their own, normal maps let a low light rake across the art, and
occluders cast shadows. This is the contract for what each piece means, what
is lit, and where the limits are.

### Turning it on

```json
"sindri.environment": {
  "lighting_2d": { "enabled": true, "ambient": [0.3, 0.3, 0.42] }
}
```

Off by default, and off means every picture is its own color, exactly as
before. On, every lit pixel is multiplied by the light reaching it: the
ambient color plus each light's contribution. The clear color is never lit.

What is lit: world sprites, tilemaps, voxel worlds viewed as maps, flecks and
world shapes. A sprite opts out
with `"unlit": true` (a glow, a pickup that should read in the dark); an
additive shape is light itself and is never darkened. The UI and screen-space
content are never lit.

### Lights

`sindri.light2d` is a point of light at its entity's world position:

| Field | Meaning | Default |
| --- | --- | --- |
| `enabled` | Whether it lights anything | `true` |
| `color` | RGBA; alpha scales the intensity, so a script can fade it like a sprite | warm white |
| `intensity` | Multiplies the color; above 1 brightens past the art | `1` |
| `radius` | How far it reaches, in world units | `4` |
| `falloff` | How it fades toward the radius: `pow(1 - d / radius, falloff)`. 1 is linear, 2 quadratic, near 0 nearly flat | `1.5` |
| `height` | How far above the plane it sits, read only by normal-mapped art | `1` |
| `shadows` | Whether occluders block it | `false` |
| `shadow_reach` | How far past an occluding edge light still reaches, so the face of a wall the light falls on is lit | `0.1` |
| `shadow_source_radius` | Emitting-area radius in world units; zero is a point light | `0` |

It is its own component rather than a kind of `sindri.light`: a 3D light shades
surfaces by the way they face, and a 2D light lights pictures, which face only
the viewer. A frame carries at most 16 lights (`MAX_LIGHTS_2D`): the ones whose
reach comes nearest the camera, so a level can hold many more than it ever shows
at once. A light that is not finite, or has no radius or falloff, is refused and
named.

A large, nearly flat light high over a level (`radius` 80, `falloff` 0.05) is a
sun. With `shadows` on, a roof keeps it out of a cave.

### Normal maps

A sprite or a tilemap may name `normal_map`: a second image laid out exactly
like its texture, whose colors are the directions the surface faces (red is
right, green is up the world, blue is out of the picture; the usual
"OpenGL" convention). The same sprite rectangle reads both images, so a sheet
and its normal map are drawn as one. Rotating or flipping a sprite turns its
normals with it.

With a normal map, a light's contribution is also multiplied by how squarely
the surface faces it, `max(dot(normal, normalize(to_light, height)), 0)`. A low
light rakes across the art and shows its relief; a high one lights it almost
flat. A sprite with no normal map is lit by distance alone, exactly as without
one. Sprites with different normal maps are different batches.

A voxel world viewed as a map draws each column from whichever texture its
block's top face names, so it names its normal maps by texture instead:

```json
"sindri.voxel_world": {
  "view": "map",
  "normal_maps": { "textures/terrain.png": "textures/terrain_n.png" }
}
```

A square whose texture has no entry, or whose normal map has not bound, is lit
by distance alone. The flood overlay reads the normal map of the texture its
water block draws from.

The platformer's `art/draw.py` writes `textures/tiles_n.png` from the same
brick, plank and dirt layouts as `tiles.png`, as a worked example. A map need
not be drawn: the editor's Normal map tab makes one from a sprite and lights it
with these same lights as it is tuned (`docs/normal-map-studio.md`); the
platformer's `hero_n.png` was made there.

### Shadows

`sindri.light2d.occluder` blocks lights that cast shadows:

| `shape` | Edges |
| --- | --- |
| `box` | A rectangle of `size`, centered on the entity |
| `polygon` | The closed outline through `points`, at least two |
| `tilemap` | The solid cells of the tilemap on the same entity, merged into boxes exactly as `sindri.physics2d.tilemap_collider` merges them; that collider's `passable` sprites let light through too |

All in the entity's own space, placed by its world transform. Each shadow
casting light gets a one-dimensional polar depth map, built on the CPU from
every occluder edge within its radius: for each of 256 directions, the distance
to the nearest edge. A pixel further than that, past the light's
`shadow_reach`, is in shadow; three neighbouring directions are averaged, which
softens the edge by about a degree and a half. Built on the CPU because it is
the same arithmetic on every host and testable without a GPU
(`Lighting2d::at` answers what a point receives).

A positive `shadow_source_radius` uses five shadow maps from points across the
light source, averaging their visibility to approximate an area-light penumbra.
The CPU query and GPU renderer use the same samples and angular filter. It is
a five-sample approximation, so bands and aliasing can remain; it is not a
continuous area-light solution. It costs five map builds per shadowed light.
Lamplighter uses it on Wick’s lantern and its roadside lamps.

Limits: the maps are rebuilt every frame from every occluder, which is cheap
for a handful of lights and a level's few dozen merged boxes and is not meant
for hundreds of shadow casting lights. A shadow's edge resolution is angular,
so a light far from what it shadows draws a softer, coarser edge. Occluders
block light only; they do not darken themselves beyond `shadow_reach`, which is
why a sun's reach is set to the depth of the ground it lights.

### In the editor

Create offers Light 2D, and Add Component offers Light 2D and Light Occluder
2D. The Scene view draws every light's
radius and every occluder's outline; a selected light's radius and a selected
box occluder's size drag by their handles as one undo step. Polygon points are
edited in the inspector.

### In Decay

`this.light2d` and `this.entity.light2d` read and write `color` (and its
`r`, `g`, `b`, `a`), `intensity`, `radius`, `falloff` and `height`. A light is
switched off with its entity's `set_active`.

### Proof

Lamplighter uses a carried lantern in a dark level, with lit roadside lanterns
and two hanging cave lanterns. The level tiles are normal-mapped, and the cave
pillar casts a shadow from the lanterns. `games/platformer/tests/cave_lights.rs` checks the shadows,
`crates/sindri-gpu/tests/sprite_lights.rs` reads the pixels back.

Low Tide lights a voxel map (a genre showcase using a capability that existed;
normal maps on a map's squares were added for it). Its sun is one wide light
kept to the north-west of the camera and recoloured by `scripts/daylight.decay`
as the tide turns: day at low water, a golden hour as it runs out, moonlight at
high water. The crawler's cabin lamps, headlights and the crew's lantern come on
at dusk, and a wreck's box occluder throws a shadow in the headlights.
`art/normals.py` writes every lit picture's normal map from the picture itself,
reading brightness as height. `games/low-tide/tests/the_basin_is_lit.rs` checks
the cycle and the lamps.

---

Source: `docs/particles.md`

## Particles

`sindri.particles` is an emitter: while it plays it keeps throwing particles
from where its entity stands. Its particles are flecks in the same pool a
`sindri.effect.burst` throws into (`docs/effect-scaling.md`), so they cost what
flecks cost: no entities, no components, nothing a script can hold or anything
can collide with. They draw like world sprites, through the same batching, and
2D lighting reaches them.

### Fields

| Field | Meaning | Default |
| --- | --- | --- |
| `playing` | Whether it is emitting | `true` |
| `texture` | The image every particle draws; one batch for the emitter | — |
| `rate` | Particles a second while playing | `10` |
| `max` | The most of its particles alive at once | `200` |
| `lifetime`, `lifetime_spread` | Seconds each lives, varied by that fraction of itself | `1`, `0.2` |
| `speed`, `speed_spread` | World units a second, varied likewise | `2`, `0.3` |
| `direction` | Degrees they leave in: 0 right, 90 up | `90` |
| `spread` | How wide a cone round `direction`, in degrees; 360 is every way | `30` |
| `shape` | Where they start: `point`, `circle` (inside), `box`, `ring` (on the edge) | `point` |
| `offset` | Where the shape sits from the entity: a runner's feet, not its middle | `[0, 0]` |
| `shape_size` | The shape's width and height; a circle or ring reads the width | `[1, 1]` |
| `gravity` | Added to each particle's velocity every second | `[0, 0]` |
| `drag` | How much of its speed a particle loses each second | `0` |
| `size`, `size_end` | Its size when thrown and when it dies, blended over its life | `0.1`, `0.1` |
| `colors` | Colors evenly spaced over a particle's life, blended between: two is a fade, three a flare that cools | white to clear |
| `random_rotation` | Whether each starts turned a random way | `false` |
| `spin` | Degrees a second it turns | `0` |
| `space` | `world`: particles stay where thrown (a trail). `local`: they move with the emitter (a halo) | `world` |
| `layer` | The sprite layer they draw on | `0` |

An emitter owes `rate` times each step's length and throws the whole particles
of it, carrying the remainder, so a rate of 2.5 a second really is 2.5 a second
at any frame rate. An emitter whose numbers cannot be used (a lifetime of zero,
a negative size, anything not finite) throws nothing, and the step reports it
naming the field.

### Determinism

Particles draw their directions, speeds, lifetimes and starting places from the
effect pool's own random stream, never the run's. Turning an emitter up cannot
change which enemies spawn, and a seeded run is the same run however much dust
it kicked up.

### In Decay

| Call | |
| --- | --- |
| `Effects.play(entity)` | Starts the entity's emitter (writes `playing`) |
| `Effects.stop(entity)` | Stops it; particles already out live out their life |
| `Effects.emit(entity, count)` | Throws that many at once, whether or not it plays: dust on landing |

### In the editor

Add Component offers Particles. While nothing is playing, the selected emitter
runs in the Scene view, and only that one: a scene full of chimneys would
otherwise smoke the whole time anyone edited it. Selecting something else starts
from nothing.

### Limits

One texture per emitter and no sheet animation over a particle's life. No
sub-emitters, trails or collision. A pool holds 8,192 flecks and particles
together; past that the oldest go first and the overflow is counted.

### Proof

The platformer: every coin sparkles from `prefabs/coin.prefab`, and the hero
trails dust while it runs and puffs on a hard landing, switched by
`scripts/hero.decay`. `games/platformer/tests/sparkle_and_dust.rs` checks both. Low Tide's treads throw
dust and its engine smokes while the crawler drives, and a swimmer and a diver
throw spray and bubbles (`games/low-tide/tests/the_basin_is_lit.rs`).

### Particle Lab

Particle Lab is an editor window and a browser tool. The preview advances the
same `Effects2d` emitter simulation as a game, using the live values in the
form. Six starter presets, a pause control, bursts, and background colors help
with exploration. The browser copies or downloads a `sindri.particles`
component as JSON and can reload pasted component JSON. In the editor, select an entity with a Particles component,
open Particle Lab and use Apply to write a validated component in one undoable
edit. A changed selection prevents accidentally applying to a different entity.

The editor loads the selected emitter’s project texture when available. Drop an image onto the preview, or use the browser image picker, to preview another texture on rotating particles. Without an
image, the preview uses circles. It does not reproduce scene lighting, so check
the final look in the Scene view. A texture reference must name an image in the
project; preset selection preserves the current reference. The emitter has the
same limits as the rest of the engine, described above.

---

Source: `docs/nine-slice.md`

## Nine-slice sprites

A sliced image keeps its border at the size the art drew it and stretches only
what is inside. One small picture of a frame becomes a panel of any size, a
board of any width, a bar whose rounded ends stay round.

### The border is the art's

The sheet beside an image says where its border runs, in pixels, by sprite
name. The empty name is the whole image:

```json
{
  "format_version": 1,
  "size": [24, 24],
  "grid": { "columns": 1, "rows": 1, "names": ["panel"] },
  "borders": { "": [6, 6, 6, 6] }
}
```

The four numbers are left, top, right and bottom. A border is pixels, so the
sheet must know the image's size: `size`, or the grid's own `size`. A sheet with
borders and no size is refused, as is a border naming a sprite the sheet does
not cut or one wider than its sprite. An image drawn whole with a sprite of its
own sheet uses that sprite's border, or the whole image's when the sprite has
none.

### Drawing sliced

`sindri.sprite` and `sindri.ui.image` both take:

| Field | Meaning | Default |
| --- | --- | --- |
| `draw` | `simple` stretches the whole picture; `sliced` keeps the border | `simple` |
| `border_scale` | How big one border pixel is drawn | sprite `1/16` world unit; UI `2/720` screen unit |

The image's size is its transform's scale, as it always was. Sliced, it is nine
quads: the corners keep their size, the top and bottom edges stretch across, the
sides stretch down, and the middle stretches both ways. They are drawn in the
image's own space, so a sliced image turns, flips and anchors as a whole one
does, and they share one draw call. An image smaller than its two borders
squeezes them in proportion rather than letting them cross.

The sprite default draws sixteen art pixels to a world unit, the size the
platformer's tiles are drawn at. The UI default draws one art pixel per screen
pixel on a 720-pixel-tall window and grows with the window as the rest of the UI
does.

A sliced UI image that fills (`fill`) shrinks as a whole, so a bar keeps its end
caps as it empties instead of cutting through one. A sliced image whose sheet
gives no border draws whole.

### In the editor

The Sprite sheet tab's inspector has a Nine-slice border section for the picked
cell, and the image shows the border as four dashed guides. Dragging a guide
moves that side a whole pixel at a time and stops it at the opposite guide.
Saving writes the border and the image size into the sheet. A border on a cell
since renamed is not written.

### Proof

The platformer's HUD frame and its signboard by the first gap are both
`textures/panel.png`, a 24-pixel panel with a six-pixel frame, drawn at very
different sizes. `games/platformer/tests/sliced_panels.rs` checks both slice.

---

Source: `docs/world-text.md`

## World text and rich text

Two additions to text, which share one renderer with the HUD's labels.

- `sindri.text` places words in the world: a damage number over an enemy, a
  name over a character, a sign over a door. It moves with its entity and the
  camera, and is drawn with the sprites of its layer.
- Rich text lets a run of the words carry its own color, bold or italic, in
  `sindri.ui.text` and `sindri.text` alike.

### `sindri.text`

Every field is `sindri.ui.text`'s and means the same thing, with two
differences that come from where the words live:

- **Sizes are world units.** `font_size`, `line_height`, `bounds`,
  `letter_spacing`, the outline width and the shadow offset are all measured
  in the world, so a number half a unit tall stays half as tall as a one-unit
  enemy whatever the camera's zoom. The overlay's two-unit ceiling on a font
  size does not apply.
- **The anchor names a point of the words, not of the screen.** `center` puts
  the middle of the words on the entity, `bottom` puts their bottom edge there,
  and so on. There is no viewport edge to anchor to.

The words sit at the entity's world position, parents included. Its scale does
not reach them: a label keeps its authored size, and a script that wants a
number to pop writes `this.world_text.font_size`.

Its rotation reaches them only with `follow_rotation: true`. Off, the default,
a label stays level however the entity under it spins, which is what a damage
number or a name plate wants. On, the words turn with the entity's world
rotation about their anchor point, shadow and outline with them, for words
painted on a crate or a sign hung at an angle.

```json
"sindri.text": {
  "text": "{.0}",
  "font": "fonts/ChakraPetch-Bold.ttf",
  "font_size": 0.34,
  "line_height": 0.4,
  "color": [0.86, 0.97, 1.0, 1.0],
  "anchor": "center",
  "layer": 20,
  "values": [10.0],
  "outline": { "width": 0.03, "color": [0.02, 0.05, 0.1, 0.9] },
  "rich": true
}
```

#### Ordering

World text is drawn in the transparent 2D stage through the world camera, one
batch per layer. Within a layer it is drawn after the sprites and world shapes,
so a number is over the thing it labels; a sprite in a higher layer covers it.
It does not test depth, the same as the HUD's text.

#### Scripts

The words are written the way a HUD label's are, with `Ui.set_text` and
`Ui.set_number`, which accept either component. The rest is on `this.world_text`:

| Path | Type |
| --- | --- |
| `this.world_text.color`, `this.world_text.color.{r,g,b,a}` | `Color`, `number` |
| `this.world_text.font_size` | `number` |
| `this.world_text.visible` | `number` |
| `this.world_text.layer` | `number` |

### Rich text

With `rich: true`, four tags in the words are read as spans instead of drawn:

| Tag | Meaning |
| --- | --- |
| `[b]…[/b]` | bold |
| `[i]…[/i]` | italic |
| `[color=#rrggbb]…[/color]` | the run's own color; `#rrggbbaa` gives it an alpha |
| `[[` | a literal `[` |

Tags nest, and `[/color]` returns to the color outside it. A tag that is not one
of these, or a closer with nothing open, is drawn as written, for the reason a
template's stray brace is: a typo should be on the screen where it is seen.

A span's color is multiplied by the text's own alpha, so fading `color.a` fades
every span with it. Bold and italic ask the face for its own weight and slant,
as the component-wide `bold` and `italic` do. A template's `{}` slots are
filled first, so a span can wrap a number: `[b][color=#ffd34d]{.0}![/color][/b]`.

Off by default, so a label that happens to hold a bracket draws as it always
did. Measurement, wrapping, auto-size and reveals all count the words without
their tags.

### Proof

Orbital's shots show their damage. Every landed shot spawns
`prefabs/damage-number.prefab`, world text that pops, drifts up and fades over
three quarters of a second; a critical hit is a bold gold rich span.
`games/orbital-baked/tests/damage_numbers.rs` plays until one lands and watches
it clear.

### Limits

- No per-glyph animation.
- No inline images or size changes inside a string; spans change color, weight
  and slant only.
- One face per string. Font fallback is still missing.

---

Source: `docs/rendering-transparency.md`

## Transparent rendering policy

Sindri treats transparency as an explicit render-order concern rather than relying on depth writes to produce accidental results.

### Blend modes

`SpriteRenderer::new` uses straight-alpha blending because `Texture2D::from_rgba8` accepts ordinary, non-premultiplied RGBA pixels. `SpriteRenderer::with_blend_mode` additionally supports:

- `Opaque` for replacement without transparency
- `Alpha` for straight-alpha textures
- `PremultipliedAlpha` when RGB channels have already been multiplied by alpha
- `Additive` for light, glow, and similar effects

A texture's encoding must match its selected blend mode. Mixing straight and premultiplied alpha produces visible fringes and incorrect color.

### Draw order

Opaque 3D passes render before transparent overlays. Transparent draws use `TransparentOrder` and are sorted in ascending key order:

1. Lower integer layer first.
2. Within a layer, greater distance from the camera first (back-to-front).
3. Exact ties use ascending submission index.

The distance is geometry rather than an authored number: how far in front of the camera the draw is, measured along the camera's forward axis rather than as a straight line to the eye, so two things side by side at the same depth sort as equally far away. `layer` is the one authored override, and it wins — a sprite in a higher layer draws in front of something nearer the camera.

The distance must be finite; NaN and infinity are rejected when the key is created. Measuring in view space rather than dividing a clip-space depth is what keeps that from being a real risk: a draw sitting exactly on the camera plane still produces a number. Submission indices make ordering deterministic across native and browser targets without treating insertion order as an undocumented side effect.

Transparent sprite passes do not write depth. Callers must sort their prepared draw list before encoding. `SpriteBatchRenderer` consumes instances in that final order and preserves it within one instanced draw for each texture/blend-mode group.

### Depth is read, never written

What a batch does about the depth the opaque stage wrote is `SpriteDepth`, and there are two answers:

- `Ignore` draws over the world whatever the depth buffer holds. A screen-space overlay is not in the world, so nothing in the world may occlude it.
- `Test` hides the batch behind opaque geometry nearer the camera, which is what being in the world means.

Neither writes. Blending is order-dependent, so a depth write would make the result depend on draw order twice: once through the sort above, and once through whichever sprite happened to reach the buffer first. The comparison is pipeline state, so the renderer holds one pipeline per answer and the frame command carries which one a batch wants.

Both attach the depth buffer read-only, so the frame must have cleared it — including a frame with no opaque geometry at all, which is what `encode_clear` is for.
