Measured endpoints

Tilesets: measured once per kind

Called out separately because these are the most capable endpoints for level art and they are relevant to the disc-golf game. pixelkiln plans a top-down set at PixelLab's own quote (Pro 15 for tiles up to 32px, 25 at 64px; standard 4, the top of its quoted 1 to 4). Every kind is measured below: Pro billed exactly its quote at 32px and 64px, and standard billed 3, under the plan.

POST /tilesets (top-down) and /tilesets-sidescroller are the current versions; /create-tileset* are the older aliases with identical schemas. Both are wrapped by pixelkiln's terrain generator (skills/pixelkiln/references/pixellab.md), the sidescroller one with terrainOrientation: "sidescroller"; the reference-image/color_image knobs below are not.

They take lower_description + upper_description (+ optional transition_description). You describe two terrains and the transition between them, and get a tileset that blends them. Distinctively, they accept both color_image and per-layer reference images (lower_reference_image, upper_reference_image, transition_reference_image). They are the only family that combines palette forcing with style anchoring.

Useful knobs: tile_size, tileset_adherence / tileset_adherence_freedom (how strictly tiles must fit together), and the usual outline / shading / detail. Pro's border controls are raggedness, roundness, boundary_amplitude with boundary_seed, step_slope, and connect_diagonals; spread_x and slope_size are documented as "No effect" now and kept only so existing calls work.

lower_base_tile_id / upper_base_tile_id connect a new set to another set's terrain. GET /tilesets/{id} (observed live) has a top-level metadata.terrain_ids of { "lower": "<uuid>", "upper": "<uuid>" }: those are the base tile ids, so set A's upper id sent as set B's lower_base_tile_id makes the two share that terrain. pixelkiln records them as terrainIds when it polls a set.

Pro at 64px, edge slope, and standard sets, measured

A second round on the same showcase project billed 46 generations in all (balance 5055 to 5009), each set exactly as below:

  • lava-floor: Pro, 64px, terrainTransitionSize: 0.5, terrainRaggedness: 0.4, terrainBoundaryAmplitude: 0.15, terrainBoundarySeed: 3, prompt 1). glowing lava texture 2). dark dungeon stone floor texture 3). crumbling dungeon brick wall. Billed exactly 25 and returned 25 tiles covering 21 distinct corner combinations, which confirms that Pro switches to the tall-wall layout at 0.5, not only at 1, and that four tiles are corner twins. The third description became the brick wall dropping into the lava.
  • swamp-marsh: Pro, 32px, terrainTransitionSize: 0.25, terrainStepSlope: 1.5, terrainRaggedness: 0.8. Billed exactly 15, 16 tiles, with a ragged bank between the water and the marsh grass.
  • lake-grass and path-grass: standard, 32px. Each billed 3, under the planned 4. path-grass used connect: { upper: { of: "lake-grass", terrain: "upper" } }, sent upper_base_tile_id, and its recorded terrain_ids.upper came back identical to lake-grass's upper id. The standard sets look flatter, with a hard edge, as PixelLab's tilesets tutorial describes.

pixelkiln keeps connected sets in one style, and a style fixes terrainMode, so a standard set cannot connect to a Pro set.

Pro top-down and a connected pair, measured

October 2026, through pixelkiln gen on the website's showcase project (website/art/pixellab-0.4.130). Two Pro sets at 32px with terrainRoundness: 2 and terrainConnectDiagonals: true:

  • ocean-sand ("deep ocean water texture" to "plain smooth sand texture") billed exactly 15 generations, 16 tiles. Its metadata.terrain_ids held both a lower and an upper id, recorded as terrainIds.
  • sand-grass ("plain smooth sand texture" to "plain short grass texture") with connect: { lower: { of: "ocean-sand", terrain: "upper" } } stayed blocked until ocean-sand was downloaded, then sent ocean-sand's upper id as lower_base_tile_id and billed exactly 15.
  • Painted together on one vertex grid, the two sets join at the shared sand with matching colour. The second set's sand carries fewer pebbles, so the ring where it meets the first set's sand is visible up close. "plain smooth sand texture" still came back with pebbles in the first set.

Sidescroller, measured

One POST /create-tileset-sidescroller call, 16px tiles, transition_size: 0.25, lower_description "mossy stone bricks texture", transition_description "green grass texture", billed exactly 3 generations (balance 5107 to 5104). PixelLab's MCP tool quotes 2 or 3.

  • The body has no mode, view, upper_description, or shape controls. tile_size is 16 or 32 only, transition_size one of 0, 0.25, 0.5, 1. lower_base_tile_id is the only connection field.
  • Submit answers { tileset_id, background_job_id, status: "processing" }, with usage: null.
  • GET /tilesets/{id} (and /tilesets-sidescroller/{id}) returns the top-down shape: 16 tiles named wang_0 to wang_15, each with corners and pattern_4x4, images base64 inline. But tileset.terrain_types is ["central", "background"] while every corner still says "lower" (the platform) or "upper" (empty, transparent air; the all-upper tile is blank). pixelkiln records ["lower", "upper"] so its corner masks work.
  • metadata.terrain_ids has lower only, and metadata.view is "side". metadata.transition_size came back 0.0 although 0.25 was sent.
  • usage is null on the GET, so the billed amount comes from GET /background-jobs/{background_job_id} (usage.generations: 3.0).

A trimmed copy of that response is test/fixtures/tileset/sidescroller-get.json.

create-tiles-pro is a different shape, a single description plus style_images, tile_view, building_* fields for structures.

Its style_images is a fourth convention, and not the one two lines up: TilesProStyleImage is flat, with all three fields required.

// create-tiles-pro
{ "style_images": [ { "base64": "...", "width": 32, "height": 32 } ] }

Sending generate-with-style-v2's nested {image: {...}} here is rejected as an extra field. Passing style images at all makes the endpoint ignore tile_type and tile_view and copy the reference's tile geometry instead. That is the only way to land new art on an existing sheet's ground plane.

GET /tiles-pro/{id} carries no status field. It answers 423 while the set is still drawing and 200 with storage_urls when it is done, so the HTTP code is the status. Cost is reported at submit time and lands on the same 20/25/40 canvas tiers as 1dir, but the canvas is tile size x variation count, not one sprite. A small tile in a large set still reaches the top tier.

All response shapes used by the client are validated at runtime. The TypeScript interfaces alone are not trusted at the HTTP boundary: missing ids, invalid object geometry, malformed tile URL maps, or balance fields with changed types produce an Invalid PixelLab response error before pipeline state is updated.

outline_mode defaults to outline, which draws a dark border around every tile. For ground tiles that is wrong: laid on a grid the borders read as quilting, with a seam at every cell edge. segmentation omits them and the same set tiles seamlessly, measured on a fairway-to-rough terrain set, where it was the difference between usable and unusable.

tile_feature and style_images are mutually exclusive. The API says "Connectable features (roads/tileset/building) cannot be combined with style tiles". So the geometry-anchoring trick above is unavailable for a connectable set, and its tiles land on whatever ground plane the view angle implies (measured: 32x24 with the diamond midline at y=7, against a 32x32 sheet wanting y=15).

tile_feature turns it from independent variations into a connectable set: roads (18-configuration path set), tileset (16-tile Wang corner set for a terrain transition; describe it as the transition, not one terrain), and building (floor/wall/doorway kit). These are sliced by index, so the returned order is load-bearing. They are structural multi-output results, not candidates to choose between: PixelKiln persists every URL in numeric order and labels the downloaded outputs tile-00, tile-01, … .

The completed response also carries tile_rules for connectable sets. Its placement contract is rule_type (edge, corner, or outline), arity (4 for square/isometric edges or corners, 6 for hex edges), connectivity (same/other), two terrain labels, and a tiles map of provider tile key to bitmask. Four-edge masks use N/E/S/W as bits 0/1/2/3. Four-corner masks use NW/NE/SW/SE as bits 3/2/1/0. Tiles absent from that map are stamp-only. PixelKiln stores the complete object in providerMetadata.pixellab.tileRules; exporters normalize the documented subset but retain the raw object in the generic file.

Given that color_image is honoured on pixflux but silently ignored on resize, verify the palette actually holds on a single tileset before committing to a set.