spritebox 0.6.0

Create 2D pixel art and game assets from text: sprites, backgrounds with parallax, tilemaps, procedural textures, UI and pixel fonts. A zero-dependency Rust library and CLI with PNG, atlas and TTF export.
Documentation

spritebox

crates.io docs.rs CI MSRV 1.85 License: MIT

A Rust library and command-line tool for creating 2D pixel art and game assets from plain text: sprites, layered backgrounds with parallax, tilemaps, procedural textures, game UI and pixel fonts. Export PNG images, packed sprite atlases with JSON metadata, TrueType fonts and preview sheets. Safe Rust, zero dependencies and deterministic output, with WebAssembly support.

No C libraries, build scripts or unsafe: only the standard library, with its own PNG encoder and decoder, DEFLATE, CRC-32, Adler-32 and TrueType writer. The same text gives the same pixels and the same PNG bytes on every platform.

spritebox is designed first for AI-assisted development. An assistant cannot draw with a mouse, but it can write precise text, render it, look at the PNG and correct the text. Everything here serves that loop: a readable format, diagnostics with a line, a column and a stable code, deterministic output, and preview sheets that show a whole file at a glance. People read and edit the same files, and diffs show which pixels changed. It is a sibling of audiobox, which does the same for game audio.

What can you make?

  • Sprites and sprite sheets: layered pixel art, animation frames, palette swaps and packed texture atlases for 2D games.
  • Tilesets and tilemaps: connected terrain with autotiling, map layouts and collision metadata.
  • Procedural textures: repeatable seeded noise, ordered dithering and palette ramps for pixel-art surfaces.
  • Game UI assets: nine-slice panels, buttons with states, icons, text labels, light/dark themes and composed menu screens.
  • Bitmap and pixel fonts: editable glyph grids, kerning, rendered text and TrueType (.ttf) export.

Use the Rust API in an asset pipeline, the CLI in a build script, or the in-memory library in a browser through WebAssembly. Text files work with version control and AI-assisted asset generation; the same source can be edited by hand.

Night Shift: an original rainy city with an animated elevated train, neon signs and a ramen stall

Night Shift — an original 480 × 300 pixel-art scene, presented at 3× with 96 frames in a four-second loop. Rain, puddle ripples, steam and train reflections are rendered with spritebox. See the Rust generator, editable sprites and reproduction instructions.

Capabilities

Capability Status
Palettes: one-character keys, #rrggbb, #rrggbbaa, transparent, copies with from= Available
Sprites: grids of keys with a declared size, in layers Available
Derived sprites: flip, rotate, offset, pad, palette swap, outline Available
Animations: frames with ticks, looping or once Available
Diagnostics with line, column and stable code; all errors at once Available
RGBA8 output, PNG writing and reading Available
Tilesets, with 16- and 47-variant autotiling from 4 or 5 hand-drawn pieces Available
Tilemaps: grids of tile indices from a legend, autotiled, with solid tiles Available
Atlases: packed sprites and frames with padding; metadata as Rust structs and JSON Available
Pixel fonts: glyph grids, text drawing, and TrueType export Available
Preview sheets Available (sprites, animations)
Imports of other files through a loader; no file system needed Available
WebAssembly (wasm32-unknown-unknown) Available
Command-line tool: check, lint, render, font Available
Seeded procedural textures: value noise and ordered dithering Available
Lint: warnings where art is valid but strays from pixel-art practice, marked on previews Available
Palette ramps, declared or made with hue shifting; groups of sprites Available
Resizable nine/three-slice panels, shadows and static text with kerning Available
Widget states, fixed-grid icon sets, themes and composed UI screens Available
Layered backgrounds, key surfaces, gradients, noise profiles, scatter and rational parallax Available
Layers in their own palettes, scale(n), tiles as sprites and multi-color text Available
Stroked shapes, backgrounds inside backgrounds and enlarged background exports Available

Install

[dependencies]
spritebox = "0.6"

Rust 1.85 or newer. The command-line tool installs with cargo install spritebox.

Example

use spritebox::{AtlasOptions, Document, PreviewOptions, png};

fn build_art(text: &str) -> spritebox::Result<()> {
    let document: Document = text.parse()?;
    // One texture with every sprite and frame, and where each one is.
    let atlas = document.atlas(&AtlasOptions::default())?;
    png::write_file("atlas.png", &atlas.image)?;
    std::fs::write("spritebox.json", atlas.to_json("atlas.png"))?;
    let coin = atlas.region("coin").unwrap(); // Rect { x, y, width, height } in texels
    // A sheet with everything, scaled up and labelled, to review at a glance.
    let sheet = document.preview(&PreviewOptions::default())?;
    png::write_file("preview.png", &sheet)
}

The command-line tool does the same for a file:

spritebox check examples/showcase.spritebox
spritebox render examples/showcase.spritebox --out out

The preview sheet of examples/showcase.spritebox

The text format

A file is a list of definitions. Each starts at the first column with a directive and a name, and the lines under it are indented with spaces: any number, the same for every line of the block. Tabs are not allowed in indentation. ; starts a comment that runs to the end of the line, and blank lines are ignored. Names use ASCII letters, digits, _ and -. Files are UTF-8; the extension .spritebox is a convention.

; A sprout in two layers, sprites derived from it, and animations.
palette field
  .  transparent
  k  #1a1c2c
  w  #f4f4f4
  g  #38b764
  G  #a7f070
  b  #5d275d
  p  #b13e53
  P  #ef7d57
  o  #f4b41b
  y  #ffe478

palette dusk from=field   ; field's keys; these replace or add
  p  #3b5dc9
  P  #73eff7

sprite sprout 12x12 palette=field
  layer body
    .....gG.....
    ....gGGg....
    .....gg.....
    ...pppppp...
    ..pPPPpppp..
    .pPPpppppbp.
    .pPppppppbp.
    .ppppppppbp.
    .pppppppbbp.
    ..pppppbbp..
    ...pp..pp...
    ...kk..kk...
  layer face
    ............
    ............
    ............
    ............
    ............
    ...wk..wk...
    ...kk..kk...
    ............
    .....kk.....
    ............
    ............
    ............

sprite sprout_left = sprout:flip_x
sprite sprout_outlined = sprout:pad(1):outline(k)

sprite coin 8x8 palette=field
  ..kkkk..
  .kooyyk.
  kooyywok
  kooyyook
  kooooook
  koooooyk
  .koooyk.
  ..kkkk..

animation sprout_idle loop
  sprout              20
  sprout:offset(0,1)  20

animation sprout_hurt once
  sprout:swap(dusk)   4
  sprout              4

The full file, with a spinning coin, is examples/showcase.spritebox.

Imports

import NAME brings in the definitions of another file, as if they were written in place of the line: a palette shared by several files, say.

import shared/palettes.spritebox

sprite coin 8x8 palette=dawn
  ...
  • Names. A name has no spaces. It resolves from the importing file's directory, with / between parts and . and .. resolved; a name that starts with / does not.
  • Loading. The crate reads no files itself: Document::parse_with_loader takes a loader, a function from a name to the file's text. The command-line tool reads from disk, relative to the file it checks; a game can read from assets embedded in its build. Document::parse, which has no loader, reports each import as an error.
  • Rules. A file is imported once, however many files import it. Circles are a cycle error, imports nest at most max_nesting deep, and all the files together stay within max_text_bytes. An error in an imported file names it in Error::file.

Palettes

palette NAME [from=OTHER], then one line per color: a key and a color.

  • Keys are one character: any character but spaces, control characters and ; ( ) , =. Keys are case-sensitive, and need not be ASCII.
  • Colors are #rrggbb, #rrggbbaa or transparent: sRGB, with alpha that is not premultiplied. Hex digits may be either case.
  • from=OTHER starts the palette with the keys and colors of OTHER, which may be defined anywhere in the file. The palette's own lines replace colors of keys it shares and add new keys. Chains of copies are allowed; circles are not.
  • ramp NAME KEYS names the keys that shade one material, dark to light, as one word, such as ramp grass abcd. Lint checks that a ramp's hue turns and its steps can be told apart. A copy keeps the ramps of the palette it copies, unless it writes one of the same name.
  • ramp NAME KEYS base=#rrggbb shift=DEGREES defines those keys with colors made around the base: lightness in even steps of OKLab L, centred on the base's where it fits; the hue turned toward yellow in the highlights and blue in the shadows, up to shift degrees at the ends (default 20, at most 60); and chroma easing toward both ends. Fixed-point arithmetic makes the same colors everywhere.
palette dawn
  .  transparent
  k  #1a1c2c
  ramp skin abcde base=#d08159        ; #77403a #a35f4b #d08159 #ecae80 #feddbc

Sprites

sprite NAME WxH [palette=PALETTE], then H rows of W keys each.

  • Each row is one word: no spaces between keys. A comment may follow it.
  • Every key must be in the palette. palette= may be left out when the file defines exactly one palette.
  • A key's color is drawn as is, so a key whose color is transparent gives a transparent pixel.

Panels. slice=N, or slice=LEFT,TOP,RIGHT,BOTTOM, marks a sprite as a nine-slice panel: the borders, in pixels from each edge, whose corners keep their size while the edges and the middle stretch. The borders must fit in the sprite. A sprite derived from a panel keeps its borders through flips, rotations, padding and palette swaps (they follow the pixels), loses them through an offset, and may write its own: sprite button_pressed = button:swap(dark) slice=3.

Resize a panel with panel:size(120x40) (nearest-neighbour stretch), or panel:size(120x40,tile) to repeat the edges and centre. Corners and slice= remain unchanged. A three-slice bar has zero insets on its fixed axis, such as slice=4,0,4,0; its height cannot change. Targets smaller than the borders, or expanded centres without source pixels, are errors. Rust has Sprite::resize and resize_with, with Fill::Stretch or Fill::Tile.

Layers. Instead of rows, a sprite may have layers, drawn bottom first:

sprite hero 16x16 palette=field
  layer body          ; rows of keys, as above, indented under the layer
    ...
  layer shadow = blob:offset(1,1)   ; or another sprite, through a modifier chain

Every layer has the sprite's size; a layer made from another sprite must have the same size. Layers are composited with the "over" operator on sRGB values, in integer arithmetic, so translucent colors blend the same on every platform.

A layer made from a sprite in another palette keeps that palette, so one sprite can combine, say, a stone in the colors of its rank with a glyph in the color of its element:

sprite rune 16x16 palette=rank
  layer stone = stone_gold      ; drawn with palette rank
  layer glyph = glyph_frost     ; keeps palette frost

A stone in palette stone, a glyph in palette frost, and the two runes made of them, the second with its glyph swapped to palette ember

Each layer is colored by its own palette, then the layers are composited as above. flip_*, rot*, offset, pad, size and shadow work on such a sprite; outline(KEY) and shadow(KEY,...) take KEY from the sprite's declared palette. swap(...) is an error that names each layer's palette, and a sprite that mixes palettes cannot be a tile. Themes recolor each layer drawn with their palette. Rust has Sprite::mixes_palettes and Sprite::layer_palettes.

Procedural textures

The preview sheet of examples/textures.spritebox

texture NAME WxH [palette=PALETTE] [seed=N] draws seeded noise with a ramp of palette keys, for grass, stone, sky and the like:

texture grass 16x16 palette=ground seed=7
  noise cell=8 octaves=3 roughness=60
  ramp h g g G        ; keys from low noise to high; repeat one to widen its band
  dither bayer4       ; none (default), bayer2, bayer4 or bayer8
  • Noise. Value noise: random values on a lattice of points cell pixels apart, eased between them. Each further octave (1 to 8, default 1) halves the cell, and weighs roughness percent (default 50) of the one before. The cell must divide the width and the height, and the noise wraps around them, so a texture tiles with itself without a seam.
  • Seed. seed= (default 0) picks the lattice values, through the crate's own hash; the same seed gives the same pixels on every platform.
  • Ramp. The noise is stretched to its full range, then mapped onto the keys. dither none takes the nearest key; bayer2, bayer4 and bayer8 mix neighboring keys in an ordered pattern, in proportion to where the value falls.
  • A texture is a sprite. Use it as one, as a layer (layer face = stone), a tile (tile dirt = dirt), a terrain's piece (fill = dirt), the start of a modifier chain (grass:flip_x) or an animation frame.

Derived sprites and modifiers

sprite NAME = CHAIN defines a sprite from another through a modifier chain: a sprite's name and :modifiers, applied left to right, written without spaces. The same chains name the frames of animations and the layers of sprites.

Modifier Result
flip_x, flip_y Mirrored left to right, or top to bottom
rot90, rot180, rot270 Rotated clockwise; a quarter turn swaps width and height
offset(dx,dy) Pixels moved right and down (negative: left and up) within the same size; pixels moved out are lost
pad(n) n empty pixels added on every side
scale(n) Every pixel enlarged to an n × n block, n from 2 to 8, with no filtering; slice insets are multiplied by n
swap(PALETTE) The same keys colored by another palette, which must have every key the sprite uses
outline(KEY) A one-pixel outline of KEY on the transparent pixels that share an edge with visible ones, as a new bottom layer; pad first if the shape touches the edges
size(WxH[,tile]) Resize a panel while retaining its corners and slice insets; stretch by default
shadow(KEY,dx,dy[,hard|long]) An offset silhouette below the sprite; grow the canvas and slice borders on that side. Offsets are -8..=8; long joins the original and offset silhouettes

One rune at 1x, scale(2) and scale(3)

sprite coin_night = coin:swap(dusk)
sprite arrow_down = arrow_right:rot90
sprite gem_glow = gem:pad(1):outline(y)

Sprites may be derived from derived sprites, in any order in the file, but not in a circle.

Tiles as sprites. A chain may also start from a tile of a tileset, written TILESET.TILE, wherever a chain starts: derived sprites, layers, animation frames, screens, tiles of other tilesets, and place sprite and scatter variants in backgrounds. The tile has the tileset's tile size and palette:

sprite lone_tomb = graveyard.tomb2:flip_x
background menu 480x270
  layer props 480x270
    place sprite graveyard.tomb2 at=40,200 anchor=bottom-left

A tilemap's tombstone tile, and the same tile as a sprite, scaled up in an inventory slot

A sprite whose name is the same dotted name wins. Only tile entries are sprites this way, not a terrain's pieces or variants. An unknown tileset or tile is an error that lists the tilesets, or the tileset's tiles.

Shadows use ordinary palette colors, including alpha, and the same integer source-over as other layers. Long-shadow overlaps are drawn once, so translucent shadows do not get darker at overlapping steps. Rust has Sprite::shadow and shadow_with, with Shadow::Hard or Shadow::Long, and Sprite::scale and scale_with. Growing modifiers enforce image, sprite and layer limits before allocating their result.

Animations

animation NAME [loop|once], then one frame per line: a sprite's name or a modifier chain, and how many ticks it shows, 1 to 65535. Diamon steps at 60 ticks a second, so 6 is a tenth of a second. loop (the default) starts again after the last frame; once stays on it.

animation coin_spin loop
  coin              6
  coin_edge         6
  coin_thin         6
  coin_edge:flip_x  6

Flipped frames. An engine flips a sprite when it draws it, so a frame states its flips instead of storing flipped pixels: the trailing flip_x, flip_y and rot180 of a frame's chain become its flip_x and flip_y (rot180 is both), and the frame's sprite is the chain without them. coin_edge:flip_x is coin_edge, drawn with flip_x. Any other chain builds a sprite named by the chain, such as coin:swap(dusk); Document::sprite finds it, and Document::frame_sprites lists them.

Tilesets

The preview sheet of examples/tiles.spritebox

tileset NAME WxH [palette=PALETTE] [columns=N], then its tiles: plain tiles, and terrains whose variants spritebox builds.

tileset ground 8x8 palette=ground
  tile brick            ; rows of keys, as for a sprite
    rrrRrrrR
    ...
  tile spikes = spike   ; or a sprite of the tile's size and palette, through a chain
  terrain dirt 47       ; five pieces; spritebox builds the 47 variants
    island
      .kkkkkk.
      ...
    h_strip
      ...
    v_strip
      ...
    inner
      ...
    fill
      ...
  terrain stone 16      ; four pieces; spritebox builds the 16 variants
    island = stone_island
    h_strip
      ...
    v_strip
      ...
    fill
      ...

Tile indices start at 1, so that 0 can mean an empty cell. Tiles are numbered in the order written, and a terrain takes 16 or 47 consecutive indices, its variants in order; variant v of terrain dirt is named dirt/v. The tileset's texture is a grid columns tiles wide (8 by default, fewer for fewer tiles), tile 1 at the top left, row by row, with no spacing.

Autotiling

A terrain is drawn as a few whole tiles, its pieces, each the tile as it looks with certain neighbours of the same terrain:

Piece The tile when it connects to It shows
island nothing edges on all four sides, outer corners
h_strip east and west edges at the top and the bottom
v_strip north and south edges at the left and the right
inner north, east, south and west, but no corner no edges, an inner corner in each corner (47 only)
fill all eight neighbours no edges

A 16 terrain needs island, h_strip, v_strip and fill; a 47 terrain needs inner too. Pieces may be rows or PIECE = CHAIN. The tile size must be even.

Each variant is assembled from quarters. A quarter looks at the two edge neighbours beside it (north or south, and west or east) and the corner between them, and is copied from the same quarter of one piece:

Edge neighbour north/south Edge neighbour west/east Corner Quarter from
no no any island
no yes any h_strip
yes no any v_strip
yes yes no inner
yes yes yes fill

So the north-west quarter of a tile with land to the north and west but not north-west comes from inner, and shows its inner corner. A 16 terrain ignores corners and treats them as connected, so it never needs inner.

Variant order. Neighbours are bits, clockwise from north: N = 1, NE = 2, E = 4, SE = 8, S = 16, SW = 32, W = 64, NW = 128.

  • A 16 terrain's variant is N + 2·E + 4·S + 8·W, with each letter 1 when that neighbour connects: variant 0 is an island, 15 is surrounded.

  • A 47 terrain first clears each corner bit unless both edge bits beside it are set, since a corner matters only then. The variant is the position of that mask among the 47 such masks in ascending order:

    0, 1, 4, 5, 7, 16, 17, 20, 21, 23, 28, 29, 31, 64, 65, 68, 69, 71, 80, 81, 84, 85,
    87, 92, 93, 95, 112, 113, 116, 117, 119, 124, 125, 127, 193, 197, 199, 209, 213,
    215, 221, 223, 241, 245, 247, 253, 255
    

    Variant 0 is an island, and 46 (mask 255) is surrounded.

Scheme::variant and Terrain::index compute this in Rust, and BLOB_MASKS is the list. The tests check all 256 neighbour masks against these rules, for both schemes.

Tilemaps

tilemap NAME [tileset=TILESET] [outside=same|empty], then a legend and a map:

tilemap level tileset=ground
  legend
    .  empty
    #  dirt    solid    ; a terrain: autotiled
    b  brick   solid    ; a tile
    5  dirt/5           ; one variant of a terrain, fixed
    ^  spikes
  map
    ..........bbbb....
    ...###............
    ...###...^^^......
    ##################
  • Legend. One line per key: one character, then empty, a tile, a terrain or a terrain's variant TERRAIN/N, then solid if those tiles are solid.
  • Map. Rows of legend keys, all of the same width.
  • Autotiling. A terrain cell takes the variant for its neighbours of the same terrain, fixed variants of that terrain included. Different terrains do not connect to each other. With outside=same, the default, cells beyond the map's edges connect, so ground runs off the screen; outside=empty closes the edges.
  • Solid tiles. The map's solid set holds the indices of every solid legend entry, all the variants of a solid terrain included. An index cannot be solid under one key and not under another. tileset= may be left out when the file has one tileset.

Pixel fonts

The preview sheet of examples/font.spritebox

font NAME height=H [baseline=B] [line_height=L] [spacing=S] [space=N] [covers=SETS], then a glyph for each character, with rows of # (ink) and . (none):

font hud height=11 baseline=9 line_height=13 space=3 covers=ascii,spanish
  glyph A
    .....
    .....
    .###.
    #...#
    #...#
    #####
    #...#
    #...#
    #...#
    .....
    .....
  glyph U+003B             ; `;` starts comments, so write it by its code
    ..
    ..
    ..
    ..
    .#
    ..
    ..
    .#
    #.
    ..
    ..
  • Metrics. Every glyph has height rows; their widths may differ. baseline is how many rows are above the baseline (default height: no descent), and line_height the distance between baselines (default height + 1).
  • Advance. A glyph's advance is its width plus spacing (default 1), unless it says advance=N. A glyph may have no rows: a blank of width 0, such as a space. Without a glyph for the space, the font gets one whose advance is space (default half the height, rounded up).
  • Characters are written as themselves, or as U+ and 4 to 6 hexadecimal digits.
  • Coverage. covers=ascii,spanish makes check report every character of those sets that has no glyph. The sets are ascii (printable ASCII, U+0020 to U+007E), latin1 (U+00A0 to U+00FF) and spanish (á é í ó ú ü ñ Á É Í Ó Ú Ü Ñ ¿ ¡). examples/font.spritebox covers ASCII and Spanish: 11 pixels tall, with 2 rows for accents above the capitals and 2 for descenders.

Font::render draws text, with \n for new lines, and Font::to_ttf writes the font as TrueType:

  • Outlines. Each glyph's outline is its pixels as squares, merged where they share an edge, with corners only: outer contours clockwise, holes counter-clockwise, and pixels that touch only at a corner in separate contours. Font::outline returns it.
  • Metrics. The em is the glyph height, at 128 font units a pixel (fewer for fonts taller than 128 pixels or glyphs wider than 250). Ascent is the rows above the baseline, descent the rows below, and the line gap line_height - height; advances are the glyphs'.
  • Crisp sizes. Draw the font at a whole multiple of its height in pixels: an 11-pixel font at 11, 22 or 33 pixels (PxScale::from(22.0) in ab_glyph), with the pen on whole pixels. Every font pixel is then a whole square of screen pixels.
  • Tables. The ten tables TrueType requires: cmap (formats 4 and, beyond the Basic Multilingual Plane, 12), glyf, loca, head, hhea, hmtx, maxp, name, OS/2 (version 4) and post. Glyph 0, .notdef, is a hollow box. There is no hinting; kerning adds an optional kern table. The file holds no time stamp, so the same font gives the same bytes.
  • Checked. The tests read the file back table by table. With SPRITEBOX_FONT_TESTS=1, FreeType (fc-scan) reads it and finds Spanish covered, and macOS Core Text draws every glyph exactly as its grid. cargo run --manifest-path scripts/ab-glyph-check/Cargo.toml loads it with ab_glyph, as Diamon's FontLibrary does, and checks every glyph at 1×, 2× and 3×: each pixel is fully covered or not at all.

Groups

group NAME SPRITE... names sprites that belong together, such as the icons of an inventory or the parts of a character, on one line:

group items  sword shield potion key

A group's sprites must be defined, each once. Groups appear in the metadata, and lint checks that a group's sprites match, as it does an animation's frames and a tileset's tiles.

UI

examples/ui.spritebox combines panels, text, button and checkbox states, six icons, a dark theme, a title screen and a pause menu. Its explicit lint allowances describe intentional compact corners and accent pixels.

UI kit and screens at native pixel multiples

Text

text title font=hud palette=ui color=k width=144 align=center
  START GAME

A text definition is an ordinary sprite: it works in modifier chains, layers, animation frames and screens. font= and one-character color= are required; palette= follows the same default as other sprites. Optional width= wraps at spaces; a word that cannot fit is an error. align=left|center|right aligns each line within the width. Without width, use the widest line. line_height= defaults to the font's; total height includes the last glyph's full grid and descender.

The common body indentation is removed; additional leading spaces remain, trailing spaces are trimmed, and indented blank lines add a line. A whole word such as U+003B inserts the corresponding character (here ;), using the glyph-name escape syntax. Missing glyphs are errors at their source character, rather than fallback glyphs. Rust has Font::sprite/sprite_with and TextOptions with Align.

Colors inside a text. {KEY} switches to palette key KEY for the characters after it, across line breaks, and {} returns to color=:

text loot font=hud palette=ui color=g width=120
  {b}+12 %{} damage

The text "+12 % damage" with the number in blue

Kerning, wrapping and alignment are those of the same text in one color: only the keys of the glyphs change. A key the palette lacks is an error at the key. Any other { is an error; write the whole word U+007B for a literal {. Rust has Font::sprite_runs/sprite_runs_with, which take (key, text) runs.

Fonts accept optional kern AV -1 entries, or escaped pairs such as kern U+0041,U+0056 -1. Both glyphs must exist. An adjustment cannot move the pen before its preceding glyph. Font::kern returns the adjustment in pixels; Font::kerning lists pairs in character order. Text rendering applies pairs, and TTF export writes a horizontal format-0 kern table when pairs exist. Fonts without pairs retain their original ten-table export. The interoperability job checks pairs with both ab_glyph and FreeType.

Widget states

states button
  normal button_frame
  hover button_frame:outline(a)
  pressed button_frame:flip_y

normal is mandatory and first. State names are free identifiers; each member is an ordinary sprite named button.normal, button.hover, and so on. Members must have equal dimensions and equal panel insets, including whether they are panels. Checkboxes can add off, on, off_hover and on_hover after normal. Document::states/state_set provide StateSet values and state lookup.

Icon sets

icons items 8x8 palette=ui outline=k margin=1
  icon sword
    ........
    ...

Each icon has exactly the declared size and becomes items.sword. outline= requires that every visible silhouette boundary pixel use that key; margin=N requires transparent outer pixels. Violations are errors. Icon headers and the set accept lint=allow(...) for deliberate art choices. Document::icon_sets and icon_set expose member order. Preview sheets give each set its own named strip. Both icon sets and state sets join the existing set consistency checks.

Themes

theme dark of ui
  k #dce9ef
  f #172d3a

A theme replaces a subset of an existing palette's keys; omitted keys retain the base colors and unknown keys are errors. Document::with_theme(name) returns a recolored document, including swap targets, tiles and screen items. Applying a different theme starts from the original palettes. Each theme targets the named palette identity; independently copied palettes keep their own colors.

Geometry is made from the base palettes and retained when themes change alpha. Documents with themes deduplicate atlas entries by palette/key structure, so recoloring cannot split or merge regions. Region coordinates remain identical.

render FILE --out DIR --theme dark writes the chosen colors to the normal filenames. --theme all writes the base atlas plus atlas-dark.png and themed tileset images, with one metadata file describing the shared layout and variants. Rust uses Document::export_all_themes/export_all_themes_with. The name all is reserved. Output names that collide, including by ASCII case on Windows, fail before writing. All retained themed images share the aggregate pixel/file budgets.

Screens

screen title 320x180 palette=ui background=u grid=2
  dialog at 80,14
  button.hover at 104,54
  start at 104,60
  items.sword at 104,132

Items are sprite chains at signed top-left pixel coordinates (+x right, +y down), drawn in order with source-over and clipped to the screen. Different sprite palettes are allowed. background= is a palette key; omit it for transparency. grid= is optional and enables position checks. Screens and screen-only chains are excluded from the atlas.

Document::screen(name) returns an optional rendered image; screen_with returns Result<Option<Image>> for explicit limits. screens() exposes definitions/items. render FILE --screen title --out title.png --scale 3 writes only that screen at an integer multiple (default 1). Directory render always includes a preview; its optional --preview flag makes this explicit. Screens appear at 1x, 2x and 3x, independently of the sprites' preview scale. --theme all needs directory render; one screen can select one named theme.

Screen lint adds screen_bounds, off_grid, and opt-in screen_overlap (opaque item pixels). Enable overlap with LintOptions::screen_overlap or lint --deny screen_overlap. low_contrast compares text to its composed backing, including backing layers in the same sprite. min_text_contrast defaults to an OKLab lightness difference of 2500/10000 (0.25). Theme-aware lint uses lint FILE --theme dark --strict. Screen diagnostics retain item positions and imported filenames, and accept ordinary lint suppression.

Backgrounds

Describe finite static scenes with palette-key surfaces and ordered native layers. Paint rectangles, lines, polygons, ellipses, banded/Bayer gradients and reusable noise profiles, and stroke rectangles and ellipses. Place sprites, textures, tiles, cropped tilemaps and other backgrounds, or scatter weighted sprite variants along a terrain profile. Themes recolor retained keys without moving generated geometry or placements.

Background generated from text

Camera movement uses exact signed rational parallax and repeat seams. Export a flattened PNG, native layer PNGs, or both, with metadata for exact reconstruction. Backgrounds stay outside sprite atlases; their PNGs retain native dimensions.

spritebox render examples/backgrounds.spritebox --out out/backgrounds --theme all
spritebox render examples/backgrounds.spritebox --out out/camera --background harbor --camera -64,0 --background-mode both

The concept art of a graveyard tower defense, in examples/first_game.spritebox: a board of tiles, stroked range rings and runes, placed whole inside the interface mockup, beside a tooltip with a scaled rune and a two-color loot text. Each picture in this README of a 0.6 feature is a panel of examples/first_game_gallery.spritebox, written with spritebox render examples/first_game_gallery.spritebox --out out/gallery --background-scale 4.

An interface mockup: a graveyard board with tombstones, range rings and runes, placed inside it, and a tooltip

Filled rectangles and ellipses, then their 1- and 2-pixel strokes; a ring keeps what is inside it

See the background reference for grammar, Rust builders, coverage rules, seed vectors, resource budgets and the reconstruction schema. The static Night Shift companion extracts representative geometry; the original animated Rust example is preserved.

Output

RGBA8. Every Image the crate makes, from Sprite::render to the atlas, is RGBA8: four bytes a pixel in the order red, green, blue, alpha; sRGB-encoded colors; straight alpha, not premultiplied; rows tightly packed, without stride padding, top row first. A 50% translucent blue is [0, 0, 255, 128], and a transparent pixel is [0, 0, 0, 0]. That is what GPU texture uploads take, and exactly what Diamon's TextureStore::register takes.

PNG. png::encode writes an indexed PNG (1, 2, 4 or 8 bits a pixel, with tRNS for colors that are not opaque) when the image has at most 256 colors, which pixel art almost always does, and 8-bit RGBA otherwise. Both are lossless. The file holds no time stamp, so the same image gives the same bytes. png::decode reads every standard PNG: all color types and bit depths, interlaced or not, with tRNS. It returns RGBA8, rounding 16-bit samples and ignoring gamma and color-space chunks. It checks CRCs and rejects unknown critical chunks. Compatibility is tested against files written by FFmpeg and macOS Image I/O, and FFmpeg reads the crate's files.

Atlases. Document::atlas packs every sprite, and every sprite that animation frames build, into one texture. AtlasOptions sets how it samples cleanly on a GPU:

  • Padding. Each sprite gets padding pixels on every side (1 by default), so neighbors are 2 * padding apart and padding from the edges. Use 0 for textures sampled only with nearest filtering.
  • Extrusion. extrude (on by default) fills the padding with copies of each sprite's edge pixels, so linear filtering and mipmaps do not mix in transparent texels or neighbors. Off, the padding is transparent.
  • Size. max_side bounds the texture's width and height: 2048 by default, the largest side that every WebGPU device supports. Art that does not fit is a LimitExceeded error, never a second texture.

Tileset textures get the same padding and extrusion. Sprites with identical pixels share one rectangle. Packing is a skyline, tallest first, trying several widths and keeping the smallest area; it depends only on the sprites, so the same file gives the same atlas.

The Atlas holds the image, each sprite's Rect in texels, and the animations with each frame's rectangle, ticks and flips. Atlas::to_json writes the same as JSON, in this form (shortened):

{
  "format": "spritebox",
  "version": 1,
  "atlas": {
    "image": "atlas.png",
    "size": [30, 60],
    "padding": 1,
    "sprites": {
      "coin": [1, 31, 8, 8],
      "coin_edge": [1, 41, 8, 8],
      "panel": [11, 1, 16, 16]
    },
    "panels": {
      "panel": {"left": 4, "top": 4, "right": 4, "bottom": 4}
    }
  },
  "animations": {
    "coin_spin": {
      "loop": true,
      "frames": [
        {"sprite": "coin", "rect": [1, 31, 8, 8], "ticks": 6, "flip_x": false, "flip_y": false},
        {"sprite": "coin_edge", "rect": [1, 41, 8, 8], "ticks": 6, "flip_x": false, "flip_y": false},
        {"sprite": "coin_edge", "rect": [1, 41, 8, 8], "ticks": 6, "flip_x": true, "flip_y": false}
      ]
    }
  }
}

The metadata reference below lists every field.

Export. Document::export gives everything a game needs: the atlas, a texture for each tileset, the tilemaps, and a TrueType file for each font. Export::files names them atlas.png, tileset-NAME.png, font-NAME.ttf and spritebox.json, and Export::to_json adds tilesets, tilemaps and fonts to the atlas's JSON:

  "tilesets": {
    "ground": {
      "image": "tileset-ground.png",
      "size": [64, 72],
      "tile_size": [8, 8],
      "columns": 8,
      "padding": 1,
      "tiles": ["dirt/0", "dirt/1", "...", "brick", "spikes"],
      "terrains": {"dirt": {"variants": 47, "first": 1}, "stone": {"variants": 16, "first": 48}}
    }
  },
  "tilemaps": {
    "level": {
      "tileset": "ground",
      "size": [32, 9],
      "tiles": [
        [0, 0, 0, 64, 64, 0, "..."],
        [32, 32, 34, 47, 46, 32, "..."]
      ],
      "solid": [1, 2, 3, "..."]
    }
  },
  "fonts": {
    "hud": {"file": "font-hud.ttf", "height": 11, "baseline": 9, "line_height": 13, "units_per_pixel": 128}
  }

(Shortened; the file writes every tile name, row and index, one string or row a line.) tiles lists the tile names in index order, so tiles[0] is tile 1, which is at (padding, padding); tile i is at column (i - 1) % columns and row (i - 1) / columns, each cell tile_size + 2 * padding texels a side. A map's tiles are its rows from the top, each a list of indices, 0 for an empty cell. solid is ascending.

Metadata reference

spritebox.json is one object. Its members appear in this order, and objects keep the order of the file: definitions in the order written.

All coordinates are in texels (pixels of a texture) from the texture's top-left corner, with x to the right and y down. Rectangles are [x, y, width, height] and cover the sprite or tile alone, never its padding.

Field Type Meaning
format "spritebox" Identifies the file.
version integer The form's version, now 1. It changes only when a change would break a reader, who should reject a version it does not know; members may be added without a change.
atlas.image string The atlas texture's file name: atlas.png.
atlas.size [width, height] The atlas's size in texels.
atlas.padding integer Texels around each sprite, extruded or transparent.
atlas.sprites object For each named sprite, then each frame sprite, its rectangle. Identical pixels share a region; themed documents also preserve palette/key identity to keep layout stable.
atlas.panels object For each sprite with slice=, its nine-slice borders {left, top, right, bottom}, in texels inward from each edge of its rectangle.
atlas.states.NAME object State name to sprite name, such as {"normal":"button.normal","hover":"button.hover"}. Present when state sets exist.
atlas.icon_sets.NAME array of strings Icon sprite names in authored order. Present when icon sets exist.
themes.NAME.atlas string Variant atlas filename in an all-themes export; uses the base atlas's rectangles.
themes.NAME.tilesets object Tileset names to themed image filenames, with the base tile indices and rectangles.
animations.NAME.loop boolean true: after the last frame, start again; false: stay on it.
animations.NAME.frames array The frames in order: sprite (a key of atlas.sprites), rect (its rectangle), ticks (how many fixed steps it shows, at least 1; 60 a second in Diamon), and flip_x and flip_y (draw the rectangle mirrored).
groups.NAME array of strings The group's sprites, each a key of atlas.sprites, in the order written.
tilesets.NAME.image string The tileset's texture: tileset-NAME.png.
tilesets.NAME.size [width, height] Its size in texels.
tilesets.NAME.tile_size [width, height] A tile's size in texels.
tilesets.NAME.columns integer Tiles in a row of the texture.
tilesets.NAME.padding integer Texels around each tile. Tile i (from 1) has its top-left corner at x = padding + ((i - 1) % columns) * (tile_width + 2 * padding) and y = padding + ((i - 1) / columns) * (tile_height + 2 * padding).
tilesets.NAME.tiles array of strings The tiles' names in index order: tiles[0] is tile 1. A terrain's variants are TERRAIN/VARIANT.
tilesets.NAME.terrains object For each terrain, variants (16 or 47) and first, the index of its variant 0.
tilemaps.NAME.tileset string The tileset whose indices the map holds.
tilemaps.NAME.size [columns, rows] The map's size in tiles.
tilemaps.NAME.tiles array of arrays The rows from the top, each the indices of its cells from the left; 0 is an empty cell and 1 the tileset's first tile.
tilemaps.NAME.solid array The indices of solid tiles, ascending.
fonts.NAME.file string The TrueType file: font-NAME.ttf.
fonts.NAME.height integer Pixels from the top of a glyph's grid to its bottom.
fonts.NAME.baseline integer Pixels of the grid above the baseline.
fonts.NAME.line_height integer Pixels from one baseline to the next.
fonts.NAME.units_per_pixel integer TrueType font units per pixel; the em is height * units_per_pixel.

For Diamon. Register atlas.png with TextureStore::register under a URI. A sprite's rectangle is the region of diamon.sprite2d, and a panel's borders are the insets of a nine-slice panel. An animation's frames, a rectangle, ticks and flips each, are a clip of diamon.sprite_animation2d, whose sprites flip when drawn. A tilemap is a diamon.tilemap2d: its tileset's texture and tile_size, its rows of indices with 0 as empty, and its solid indices for collision. A font needs nothing new: FontLibrary takes the TrueType file, and text drawn at a multiple of the font's height stays crisp.

Preview sheets. Document::preview draws every sprite; every animation's frames in a row with their ticks; every tileset's tiles with their indices; and the top-left 64 × 36 tiles of each tilemap, beside a copy with its solid tiles tinted red. Sprites and tiles are scaled up by an integer factor (4 by default), maps by half of it, on a checkerboard that shows transparency, with lines between pixels or tiles, labelled with names, sizes and ticks in a small built-in pixel font.

From memory, and in WebAssembly

Everything from text to PNG, JSON and TrueType bytes works in memory: no file system is touched unless a caller asks, through png::read_file, png::write_file or its own loader. The library builds for wasm32-unknown-unknown, and a game running in a browser parses art embedded in its build or fetched by the page:

fn art(embedded: &std::collections::HashMap<&str, &str>) -> spritebox::Result<spritebox::Export> {
    let mut loader = |name: &str| {
        embedded.get(name).map(|t| t.to_string()).ok_or_else(|| {
            spritebox::Error::new(spritebox::ErrorCode::Io, format!("no asset {name}"))
        })
    };
    let document = spritebox::Document::parse_with_loader(
        embedded["art/level1.spritebox"],
        &spritebox::Limits::default(),
        &mut loader,
    )?;
    document.export(&spritebox::ExportOptions::default())
}

Command-line tool

spritebox check <file> [--json]
spritebox lint <file> [--strict] [--allow <code>]... [--deny <code>]... [--json]
spritebox render <file> --out <dir> [--scale <n>] [--padding <n>] [--no-extrude]
                 [--max-side <n>] [--lint] [--json] [--background <name>] [--camera <x,y>]
                 [--background-mode <mode>] [--background-scale <n>]
spritebox font <file> --out <font.ttf> [--font <name>] [--family <name>] [--json]
  • check validates a file and prints every diagnostic, as file:line:column: error[code]: message on standard error, or as JSON.
  • lint validates a file, then prints its lint warnings, as file:line:column: warning[code]: message. Warnings alone exit with 0; --strict makes any warning fail, and --deny <code> that code. --allow <code> leaves a code out. Past 1000 warnings, the first are printed and the summary says how many there are in all; with --json, omitted counts those left out.
  • render validates a file, then writes atlas.png, tileset-NAME.png for each tileset, font-NAME.ttf for each font, the metadata spritebox.json, and preview.png to <dir>. Nothing is written when the file has errors. --scale sets the preview's scale, and --padding and --extrude the atlas's. --lint marks lint's warnings on the preview's sprites, with a legend (PreviewOptions::lint in the library). Background PNGs keep their native size; --background-scale <n>, 2 to 8, also writes each of them, flat, layer or themed, enlarged n times without filtering, as background-NNNN@<n>x.png and background-NNNN-layer-NNNN@<n>x.png. The native files and the metadata are unchanged, and each enlarged image is checked against the image limits before anything is written.
  • font validates a file, then writes one of its fonts as TrueType: the only one, or the one --font names. --family sets the family name in the file.
  • <file> may be - for standard input.

Exit statuses are stable: 0 success, 1 the file has errors (or, for lint, warnings that --strict or --deny fail on), 2 bad usage (including a --font that is missing or unknown), 3 an I/O error. With --json, standard output is one JSON object:

{"version":1,"file":"bad.spritebox","ok":false,"diagnostics":[
  {"code":"unknown_name","severity":"error","line":4,"column":4,"message":"Key `x` is not in palette `p`"}
],"written":[]}

Each diagnostic has a code, a severity (error, or warning from lint) and a message; line and column when it concerns a place in the text; and file when that place is in an imported file. written lists the files that render wrote.

Errors

Errors are Result<T, spritebox::Error> values with a stable ErrorCode, a message, and for text a Position: a 1-based line, and a 1-based column counted in characters. Document::parse returns the first error; spritebox::check returns all of them, ordered by position. Malformed input gives an error, never a panic.

Code JSON Meaning
Syntax syntax Malformed text: an unknown directive, a missing value, bad indentation
InvalidArgument invalid_argument A value out of range, such as a color or a size
UnknownName unknown_name A palette, sprite or key that is not defined
DuplicateName duplicate_name A name or key defined twice
SizeMismatch size_mismatch A grid whose rows or columns do not match its size
Cycle cycle Definitions that refer to each other in a circle
LimitExceeded limit_exceeded Input or output beyond Limits
DecodeError decode_error A malformed or truncated PNG file
EncodeError encode_error Output that cannot be encoded
UnsupportedFormat unsupported_format Binary input that is not a PNG, or a PNG feature not read
Io io A file that cannot be read or written

Lint

A file can be valid and still look careless. Document::lint and spritebox lint report what pixel-art practice avoids. The result is a Lint: its warnings, each with a stable LintCode, a message, the position of the pixel's character in its row (or of the definition), the sprite and its pixels; and the counts of each code. Only the first Limits::max_warnings warnings are kept, in order, but the counts include every warning, so --strict and --deny see them all:

use spritebox::{Document, LintOptions};

fn review(text: &str) -> spritebox::Result<()> {
    let document: Document = text.parse()?;
    for warning in document.lint(&LintOptions::default()).warnings {
        eprintln!("warning[{}]: {warning}", warning.code);
    }
    Ok(())
}
Code Reports
orphan_pixel A lone pixel on a flat area, within LintOptions::orphan_contrast (default 32 of 255) in lightness of the color around it: noise rather than a detail. Higher-contrast lone pixels, such as eyes and glints, are taken as deliberate. Also an opaque pixel touching nothing at all.
jaggy Uneven steps along a sprite's silhouette: a step at least two pixels longer, or shorter, than the steps on both sides of it, as in 1, 1, 3, 1, 1. Steady lines (2, 2, 2), alternating ones (2, 1, 2, 1) and curves (1, 2, 3) are clean, and so are the first and last steps of a stretch, which corners cut short. Steep sides are measured down the rows.
double_outline An outline two pixels thick at an L corner, where one diagonal step would do. The outline is the key of at least half the silhouette's pixels, around a fill; square corners, lines meeting lines, lines that fill 2×2 squares (two pixels thick across), and strokes with no fill beside them are taken as deliberate. Each corner is taken out in turn, so what is reported is what to erase.
stray_alpha A lone translucent pixel, with no translucent pixel around it, in a sprite whose translucent pixels are fewer than one in eight: a leftover of smoothing, which blurs when the sprite is scaled. Translucent areas, such as glass, are left alone.
pillow_shading A fill (the pixels inside the outline) that darkens toward its edge on every side, with no direction of light: the centroid of its lighter half within LintOptions::pillow_offset (default 10%) of the shorter side from the fill's centroid (at 0%, exactly on it), and lightness rising with the distance from the edge. In sprites of at least 8×8 with three colors in the fill; not in panels, whose bevels are shaded so on purpose.
banding A line one pixel thick, reaching at least LintOptions::banding_length (default 4) pixels across or down, of a shade between a lighter and a darker one on its two sides: shading stacked in parallel lines. The outline is not a shade.
mixel A sprite drawn at 2×, 3× or 4× (every run of one color, across and down, a multiple of it) in a document with sprites drawn at 1×. A document drawn at 2× throughout is consistent.
too_many_colors More visible colors than LintOptions::max_colors_small (default 8) in a sprite under 32×32, or than LintOptions::max_colors (default 16) in any.
inconsistent_size A sprite of a set (an animation's frames or a group) of another size than most of it.
inconsistent_outline A sprite of a set without an outline when most have one, or with one when most have none; or, among the set's sprites drawn with one palette, outlined in another color. A palette swap, as for a flash, may recolor an outline.
inconsistent_palette A sprite of a set drawn with a palette that is not a copy of the one most of it uses.
inconsistent_scale A sprite of a set (frames, a group or a tileset's tiles) drawn at another pixel scale than most of it, by the measure of mixel.
near_duplicate_color Two used keys of a palette, alike in alpha, closer than LintOptions::min_color_distance (default 0.03) in OKLab: one would do.
flat_ramp A ramp whose hue at its two ends is within 5°: shades that only change in brightness look dull. Grey ramps are never flat.
low_contrast Neighbouring keys of a ramp closer than LintOptions::min_ramp_step (default 0.06) in OKLab lightness.
unused_key A palette key that no sprite, frame, tile or texture ramp uses, through that palette or one copied from it.

The checks of pixels (orphan_pixel to too_many_colors) look at sprites drawn with a size, as they are seen with their layers; those of lines need at least 3×3. Lightness is OKLab's L, computed in fixed point so that every platform agrees. Textures, whose noise is the point, derived sprites, which repeat their base, and font glyphs, whose dots and letterforms break these rules on purpose, are not checked.

Some art breaks a rule on purpose. lint=allow(CODE,...) on a definition allows those codes for it and for everything built from it (sprites made from a sprite, palettes copied from a palette); lint allow CODE... on a line of its own allows them in its file (not in the files that import it, even for sprites they build from its sprites); and LintOptions::allow, or --allow, everywhere:

lint allow unused_key            ; a shared palette file

sprite sparkle 5x5 palette=dawn lint=allow(orphan_pixel)
  ...

Pixel-art rules

What lint checks is what pixel artists practise, so that art made fast, or by a model, reads as made with care. examples/lint.spritebox breaks each rule once, next to the same art corrected; render --lint marks each break on the preview:

Each check's example before and after, the breaks marked

  • Lines are one pixel thick and step evenly. A diagonal climbs in steps of one length (2, 2, 2), or of lengths that grow or shrink for a curve (1, 2, 3); one step out of rhythm is a jaggy (jaggy). Where a line turns, one pixel on the diagonal is enough, not an L of three (double_outline).
  • Every pixel earns its place. A lone pixel barely lighter or darker than what surrounds it reads as dirt, and one floating free as a speck (orphan_pixel); translucent pixels left from smoothing blur when the art is scaled (stray_alpha). An eye or a glint stands out, and is left alone.
  • Light comes from somewhere. Shading that darkens toward the edges on every side looks puffed, a pillow (pillow_shading); light from the top left, and shadow down and right, gives form. Shades laid as lines a pixel thick, each following the last, show the stacking, not the shape (banding).
  • Ramps turn their hue. A material's shades go cooler (toward blue) as they darken and warmer (toward yellow) as they lighten; a ramp of one hue looks flat (flat_ramp), and steps too close in lightness cannot be told apart (low_contrast). ramp NAME KEYS base=#rrggbb makes such a ramp.
  • Few colors, each needed. Small sprites read best with eight colors or fewer (too_many_colors); two colors no eye can tell apart (near_duplicate_color), or a key nothing uses (unused_key), are noise in the palette.
  • One pixel size. Art drawn at 2× next to art at 1× shows pixels of two sizes (mixel).
  • Sets match. An animation's frames, a group, a tileset's tiles share their size, outline, palette and pixel scale (inconsistent_size, inconsistent_outline, inconsistent_palette, inconsistent_scale).

Break a rule when the art needs it: a sparkle is a lone pixel, a hurt flash swaps a palette. Allow the code where it is meant, with lint=allow(...) on the definition and a comment saying why, rather than for the whole file.

Limits

Limits bound the work that a file or a PNG can cause. The defaults suit hand-written files and untrusted input; raise them for trusted input.

Limit Default
max_text_bytes 4 MiB
max_definitions 4096
max_palette_colors 256 keys
max_sprite_side 1024 pixels
max_layers 16 layers in a sprite or background
max_frames 1024 frames in an animation
max_tiles 4096 tiles in a tileset, terrain variants included
max_map_side 1024 tiles
max_glyphs 4096 glyphs in a font
max_kerning_pairs 4096 kerning pairs in a font
max_screen_items 1024 placements in a screen
max_scene_commands 4096 scene operations and list records
max_scene_pixels 16 Mi retained scene cells and operation images
max_scene_work 64 Mi scene work units per parse/render/export
max_scatter_attempts 1,000,000 candidate attempts per document generation
max_export_files 8192 files, including theme variants
max_nesting 32 levels of palettes copied from= others, or of sprites derived from sprites
max_image_side 8192 pixels, for any image created or decoded
max_image_pixels 16 Mi pixels, for any image, and for all sprites together
max_warnings 1000 lint warnings kept; the counts include the rest
use spritebox::{Document, Limits};

fn parse_trusted(text: &str) -> spritebox::Result<Document> {
    Document::parse_with(text, &Limits { max_sprite_side: 4096, ..Limits::default() })
}

Limitations

What spritebox does not do, as of this release:

  • No drawing tools or editor: art is text, reviewed through preview sheets.
  • Fonts are one weight and style, without ligatures or hinting; outlines are exact squares, so they are crisp at integer multiples of the pixel size and blur in between, like any pixel font.
  • Terrains connect only to themselves: there are no transitions between terrains, such as grass meeting sand, and no animated tiles.
  • An atlas is one texture; when the sprites do not fit within max_side, building it fails rather than spilling into a second.
  • Color is sRGB throughout, and alpha is composited in sRGB with integer arithmetic. There is no color management: gamma, ICC and sRGB chunks of PNG input are ignored.
  • The PNG encoder writes no interlaced, 16-bit, grayscale or metadata chunks, and the decoder does not read APNG animation.
  • The preview's label font has uppercase letters, digits and common punctuation; lowercase letters are drawn as uppercase.

Development and releases

CI runs for every pull request, when a release is published, and on demand from the Actions tab. You can also run the checks locally:

cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test
cargo test --release
cargo +1.85.0 test
RUSTDOCFLAGS="-D warnings" cargo doc --no-deps
cargo check --lib --target wasm32-unknown-unknown   # rustup target add wasm32-unknown-unknown
SPRITEBOX_FFMPEG_TESTS=1 cargo test --test png   # needs FFmpeg
SPRITEBOX_FONT_TESTS=1 cargo test --test font    # needs fc-scan; on macOS, swift
cargo publish --dry-run

scripts/make-png-fixtures.py regenerates the PNG fixtures with FFmpeg and, on macOS, sips; see tests/fixtures/README.md.

To release: update CHANGELOG.md and the version in Cargo.toml, run the checks, merge to main, run cargo publish, then tag vX.Y.Z and publish a GitHub release, which runs CI on Linux, macOS and Windows, the MSRV, the WebAssembly build, and FFmpeg and FreeType interoperability.

License

MIT