maniator / maniator/verticopolis

[Discussion] Switching graphics to Raster (pixel) images + organizing imagesn (stairs as example)

Open
#643 4 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

design-decision
Dominant language
TypeScript
Stars
5
Forks
1
PR merge metrics
No merged PRs in 30d

Description

Glad to be involved w the project, this is fun. So I think we could get away with mixing vector art (better managed as SVG) and raster pixel art. And it would be easier to deal with static art that already exists as SVG instead of those JS draw statements. With alpha channel PNG as an option for various sprites and buildings, it should layer on pretty well and give a classical look more easily. (from there as well, antialiasing & render settings could be tried for different looks.)

Anyway here is a write up claude gave me re how the stairs could convert to raster art. hopefully helpful as a reference point. Later a special contributors-artwork.md file might be good to cover this space.

Also apparently with any file assets there are implications for progressive web app PWA behavior w file management (I think). Quite a lot of stuff in here I never heard of lol. It is pretty thorough


Migrating procedural sprite art to an authored format

Part 1 is a concrete walkthrough for the stair flight, replacing drawStairFlight
in src/render/sprites/transport.ts with an authored RGBA sprite.

Part 2 generalizes it: how to extract any procedural sprite in this project into a
portable form, and how to choose between SVG, raster, and a palette-indexed
intermediate. Read Part 2 first if you have not committed to raster yet, since the
choice affects what the stair work should produce.

Why this sprite is the right candidate

Stairs are fixed at 8 tiles wide and one floor tall, and both numbers are pinned
by constants that scale.test.ts asserts. The parametric machinery in the current
function (n, treadW, riseH, line(), stepTopY()) buys flexibility that
nothing will ever use. Meanwhile about a third of the function is boundary
handling: end caps, newel posts, landing joints, clamping the shadow off the
bandBottom row. That is the category of detail where drawing beats coding,
because the mistakes are invisible in review and obvious in a render.

Elevator shafts are a different case. They span a variable number of floors, tint
by kind, and draw floor numbers, so they should stay procedural.

Step 0: extract the geometry contract first

Do this as its own PR, with no visual change. It is the only part of the work that
is genuinely load-bearing.

The flight's incline exists in two places that do not reference each other.
drawStairFlight uses:

x0   = sx + 10
x1   = sx + w - 12
yBot = bandBottom - depth - 2      // depth = 7, so bandBottom - 9
yTop = bandTop + 2

src/render/excalibur/towerCrowd.ts places climbers with:

x0w = engine.worldX(t.x) + 2
x1w = engine.worldX(t.x + t.width) - 3
yb  = engine.worldYTop(t.bottom) + FLOOR - 2
yt  = yb - (FLOOR - 4)

Different insets on both axes, different vertical extent, and the crowd walks a
straight lerp while the art has a stepped top surface. They overlap today because
the numbers happen to be close. Once the art is a bitmap you can no longer nudge
the drawing to match the walkers, so the two need one source of truth.

Create src/render/stairGeometry.ts:

/** Shared flight geometry. Both the flight art (bitmap or procedural) and the
 *  climber placement in towerCrowd read these, so a walker's feet stay on the
 *  treads. Changing a number here moves both. */
export const STAIR = {
  /** Handrail height above the incline. */
  railH: 9,
  /** Stringer thickness under the incline. */
  depth: 7,
  /** Left inset of the first riser from the transport's left edge. */
  insetL: 10,
  /** Right inset of the top riser (the joint with the top stair). */
  insetR: 12,
  /** Steps per flight. */
  steps: 6,
  /** How far the handrail clears the arrival deck. railH minus the yTop inset. */
  overhang: 7,
} as const;

export interface FlightRect {
  x0: number;
  x1: number;
  yBot: number;
  yTop: number;
}

/** The incline endpoints for a flight occupying `[bandTop, bandBottom)` inside
 *  `[sx, sx + w]`. `(x0, yBot)` is the foot, `(x1, yTop)` is the top riser. */
export function flightRect(sx: number, w: number, bandTop: number, bandBottom: number): FlightRect {
  return {
    x0: sx + STAIR.insetL,
    x1: sx + w - STAIR.insetR,
    yBot: bandBottom - STAIR.depth - 2,
    yTop: bandTop + 2,
  };
}

/** Height of the incline at `x`, clamped flat past the top riser. */
export function flightLine(r: FlightRect, x: number): number {
  const t = Math.min(1, Math.max(0, (x - r.x0) / (r.x1 - r.x0)));
  return r.yBot - t * (r.yBot - r.yTop);
}

Then have drawStairFlight and towerCrowd's stair branch both call it. Ship
that, confirm the visual baselines do not move, and only then start on the art.

The sprite spec

Verified by replaying every fillRect the current function issues at production
dimensions (w = 88, FLOOR = 44):

Property Value Where it comes from
Canvas 88 x 51 px 8 tiles x TILE 11 wide; FLOOR 44 plus 7px rail overhang
Deck line row 7 bandTop in sprite space
Band rows 7 through 50 the departure floor's 44 rows
Overhang rows 0 through 6 handrail and top newel above the arrival deck
Ink extent x 9 to 85, y 0 to 50 9px transparent margin left, 2px right
Blit position (sx, bandTop - 7) one call per flight
Format RGBA PNG, 8-bit alpha see below

Keep the canvas the full 88 wide rather than cropping to the 77px of ink. The
transparent margins let an artist move the flight left or right without touching
the blit code.

Tread width works out to exactly 11px, one tile, and the rise is 5.5px per step,
so treads alternate between 5 and 6 pixels. Snap them to whichever pattern reads
better; nothing depends on the exact rhythm once the geometry module owns the
incline.

Why 8-bit alpha and not 1-bit

The drop shadow under the stringer is rgba(0,0,0,0.28), and stairs draw with no
solid backing so that shadow falls over whatever room art is behind. A binary
alpha mask cannot express it. Three options:

  1. RGBA PNG. Simplest. Gives up the strict indexed-palette workflow used
    elsewhere in the project.
  2. Indexed PNG plus a separate shadow pass. Keep the sprite 1-bit and leave
    the rgba(0,0,0,0.28) shadow loop in code, drawn before the blit.
  3. Bake the shadow at a fixed opacity into an RGBA sprite and accept that it
    reads slightly differently over dark room art than over light.

Option 2 preserves the most discipline and is what I would pick, since the shadow
is a single one-pixel-tall diagonal and is the least interesting part of the
drawing.

Producing a reference to trace

You are tracing your own output, so there is no provenance question at all.

Temporarily export drawStairFlight from transport.ts, run npm run dev, and
paste this into the browser console:

const c = document.createElement("canvas");
c.width = 88;
c.height = 51;
// bandTop 7 puts the deck on row 7; bandBottom 51 gives the band its 44 rows.
drawStairFlight(c.getContext("2d"), 0, 88, 7, 51);
console.log(c.toDataURL());

Paste the data URL into the address bar, save the PNG, and revert the export.
That is your bottom layer in Aseprite.

Authoring

Canvas 88 x 51, transparent background. Import the reference as a locked bottom
layer at 50% opacity.

Palette, lifted from the current function so the new art matches the rest of the
game:

Color Role
#8A7454 riser and stringer body
#2A2018 shaded stringer underside
#241E14 riser face at each step front
#EDE6D2 tread cap
#F8F2E0 lit nosing
#5A3E28 baluster and newel body
#6B4A2B handrail
#8A6440 handrail highlight and newel cap

Note that these live as literals in transport.ts and are not in PAL in
pixelSprites/common.ts. Worth moving them into PAL as part of this work so
the guard test covers them against the reserved state colors.

Guides to set: horizontal at y=7 (the deck), horizontal at y=50 (the last row
before the floor below), vertical at x=10 and x=76 (the incline endpoints from
STAIR.insetL and insetR).

Things worth improving while you are in there, all of which are awkward in code
and free in a bitmap:

  • Proper mitred joints where the handrail meets each newel.
  • A one-pixel darker line under each tread nosing for depth.
  • Slight variation in the riser faces so six identical steps read as carpentry.
  • A real cast shadow shape instead of the current one-pixel diagonal.
  • The top stair's join to the floor slab, which is currently a butt joint.

Loading the image

drawTransport is synchronous and takes a raw CanvasRenderingContext2D, but
image decoding is asynchronous. The sprite has to be decoded before the first
bake or the first frame draws nothing.

Import it as an inline data URL. A 88 x 51 RGBA PNG lands around 1 to 2 KB, well
under Vite's default 4096-byte assetsInlineLimit, so it inlines into the bundle
automatically. This also sidesteps the PWA precache entirely: no new entry in the
service worker manifest, nothing for scripts/verify-precache.ts to check, and
no offline failure mode.

// src/render/sprites/stairFlight.ts
import stairFlightUrl from "./stairFlight.png?inline";

let sprite: ImageBitmap | null = null;

/** Decode the flight sprite. Call once during boot, before the first bake.
 *  `drawStairFlightBitmap` is a no-op until this resolves. */
export async function loadStairFlight(): Promise<void> {
  const res = await fetch(stairFlightUrl);
  sprite = await createImageBitmap(await res.blob());
}

export function stairFlightSprite(): ImageBitmap | null {
  return sprite;
}

Await loadStairFlight() in the composition root (src/main.ts) alongside the
rest of the boot sequence, before the engine's first bake. main.ts is already
unit-exempt and e2e-covered, so this does not move any coverage floor.

The new draw function

import { STAIR, flightRect, flightLine } from "../stairGeometry";
import { stairFlightSprite } from "./stairFlight";

/** One stair flight, blitted from the authored sprite. The sprite is 88 x 51
 *  with the arrival deck on row 7, so it is drawn `STAIR.overhang` px above
 *  `bandTop`: the handrail breaks the floor line it lands on, as in the original.
 *  Structure only, NO baked climber: the engine's routed sims ride over it as
 *  separate actors. */
function drawStairFlight(ctx: CanvasRenderingContext2D, sx: number, w: number, bandTop: number, bandBottom: number): void {
  const img = stairFlightSprite();
  if (!img) return; // not decoded yet; the next bake picks it up
  const r = flightRect(sx, w, bandTop, bandBottom);
  if (r.x1 - r.x0 < 8) return; // too narrow to lay a readable flight (defensive)
  // Cast shadow stays procedural so it can sit at a true alpha over room art
  // rather than being baked against an assumed background.
  ctx.fillStyle = "rgba(0,0,0,0.28)";
  for (let x = r.x0; x < r.x1; x++) {
    ctx.fillRect(x + 1, Math.round(flightLine(r, x)) + STAIR.depth + 1, 1, 1);
  }
  ctx.imageSmoothingEnabled = false;
  ctx.drawImage(img, sx, bandTop - STAIR.overhang);
}

That is the whole thing. Roughly a hundred lines become fifteen.

The w mismatch

drawStairFlight currently adapts to whatever width it is handed, which is why
renderTransport.test.ts can call it with w = 40. A bitmap cannot. The sprite
is 88 wide because the catalog says stairs: { width: 8 } and TILE is 11.

Pick one and be explicit:

  • Preferred: update the test to pass w = 88 and FLOOR_H = 44, matching
    production. The test's arbitrary dimensions were only ever a convenience.
  • Alternative: ctx.drawImage(img, 0, 0, 88, 51, sx, bandTop - overhang, w, w * 51 / 88)
    to scale. This blurs at non-integer ratios and is not worth it.

Add an assertion in scale.test.ts pinning FACILITIES.stairs.width * TILE to the
sprite's 88, so a catalog change fails loudly instead of rendering a squashed
staircase.

Test migration

renderTransport.test.ts

Every assertion in this file inspects recorded fillRect calls. After the swap
the flight issues one drawImage and a handful of shadow rects, so the geometry
tests go quiet without failing, which is worse than failing.

Add a drawImage recorder:

  const images: { x: number; y: number }[] = [];
  // ...
    drawImage: (_img: unknown, x: number, y: number) => void images.push({ x, y }),

Then rewrite the two stair tests against blit positions, which preserves exactly
the regression the file was written for (a span-1 stairway drawing a flight in
both bands, reading as two stacked staircases):

  it("a two-floor stairway blits exactly one flight, in the bottom band", () => {
    const { ctx, images } = recordingCtx();
    drawTransport(ctx, transport("stairs", 1, 2), 0, TOP_Y, 88, FLOOR_H);
    expect(images).toEqual([{ x: 0, y: TOP_Y + FLOOR_H - RAIL_OVERHANG }]);
  });

  it("a tall stairway blits one flight per floor PAIR (span flights, not span+1)", () => {
    const { ctx, images } = recordingCtx();
    drawTransport(ctx, transport("stairs", 1, 4), 0, TOP_Y, 88, FLOOR_H);
    expect(images.map((i) => i.y)).toEqual([
      TOP_Y + FLOOR_H - RAIL_OVERHANG,
      TOP_Y + 2 * FLOOR_H - RAIL_OVERHANG,
      TOP_Y + 3 * FLOOR_H - RAIL_OVERHANG,
    ]); // 3 flights for floors 1→2→3→4, none in the top band
  });

These are stronger than what they replace, because an exact blit list catches an
off-by-one that a band-occupancy set would not.

You will also need loadStairFlight() to have run, or stairFlightSprite()
returns null and nothing is drawn. Either call it in a beforeAll (happy-dom may
not implement createImageBitmap, so check) or export a test seam that injects a
stub bitmap. The seam is less brittle.

sprites.test.ts

It does not reference stairs, so nothing to do.

Coverage

transport.ts gets shorter and much less branchy, so the per-file render floors
should be easier to hold, not harder. Run npm run test:coverage and confirm the
ratchet still passes rather than assuming.

Visual baselines

Push with [update-baselines] in the head commit. This is the tier that now
carries the whole burden of judging the art, which is the real cost of the
migration and is worth stating in the PR description.

Project plumbing

License. This is the project's first shipped art asset. ASSETS-LICENSE.md
already covers art under CC BY 4.0, so the structure exists, but confirm the PNG
is actually in scope and that CONTRIBUTING's contributor agreement language
reads correctly now that "asset contributions" means a real file.

Version and changelog. Players will notice, so this is a minor bump. A
changelog line in the house style (player outcome, one short line, present tense,
calm):

## 1.52.0

- Stairways have redrawn treads and handrails.

Reviewability. Split into three PRs:

  1. Extract stairGeometry.ts, point both drawStairFlight and towerCrowd at
    it. No visual change, baselines must not move.
  2. Add the sprite, the loader, and the boot hook, with the procedural draw still
    in place and unused. Nothing renders differently.
  3. Swap the draw path, migrate the tests, update the baselines.

Each step is independently revertible, and step 3 is the only one that can
regress the look.

What you give up

Say this plainly in the PR so it is a decision rather than a drift:

  • The spy-context unit tier can no longer judge the flight's appearance at all.
    Playwright visual baselines become the only automated guard.
  • Retheming by swapping PAL stops working for this sprite. A dark mode or a
    seasonal variant means a second PNG.
  • Contributors need Aseprite or equivalent plus the palette file, where before
    they needed only a text editor.
  • The flight is frozen at 88 x 51. If TILE or FLOOR ever move, the art is
    wrong rather than adapting.

None of those are large for stairs specifically. All four get significantly worse
if this becomes the pattern for room art, where per-unit seeded variety and the
PAL retheme are load-bearing. Worth deciding now whether this is a one-off for
an unusually fiddly sprite or the start of a general move, and writing that
decision down somewhere durable.


Part 2: Extraction in general, and choosing a format

Part 1 assumes raster. This part backs up a step. The same extraction work feeds
SVG, raster, or a palette-indexed intermediate, so it is worth deciding which one
you want before you hand-author anything.

The extraction principle

Every sprite painter in this project has the same shape: it takes a
CanvasRenderingContext2D and issues a bounded, deterministic sequence of calls
against it. That makes each one a format-agnostic scene description rather than a
raster process. Substitute a different context and you get a different output
format from unchanged drawing code.

You already rely on this. recordingCtx() in renderTransport.test.ts is a
partial implementation: a fake context that captures calls instead of rasterizing.
The extraction pipeline is that same idea with real emitters behind it.

There are two routes, and the difference matters more than it looks.

Route A, capture the draw calls. Pass a recording or SVG-emitting context and
serialize what the function asked for. svgcanvas (a maintained fork of gliffy's
canvas2svg) does exactly this: new Context(w, h), draw normally, then
getSerializedSvg().

Route B, capture the pixels. Rasterize once into an ImageData, then derive
every format from that buffer.

Route A is quicker to stand up. Route B is the one to build, for a reason that is
easy to miss: the draw calls are shaped like the code that made them, not like the
picture. One stair flight issues 298 fillRect calls, 288 of which are 1px-wide
columns
, because the stringer, handrail, and shadow are all per-column loops.
Serialized naively that is 298 <rect> elements and roughly 15 KB of markup for a
sprite whose PNG is under 2 KB. Nobody can open that in Inkscape and edit it.

Route B loses that coupling. Trace the pixel buffer and you get shapes: one path
for the handrail, one for the stringer, one for the treads. Around ten elements
instead of 298, and the result matches the raster exactly by construction, because
both came from the same buffer.

The extraction harness

One script, one sprite registry, several emitters. The registry is worth having on
its own merits since it also gives the gallery page a single source of truth.

// scripts/extractSprites.ts
import { TILE, FLOOR } from "../src/render/scale";
import { FACILITIES } from "../src/engine/facilities";
import { STAIR } from "../src/render/stairGeometry";

/** A sprite the pipeline can extract. Canonical size derives from the catalog
 *  and the world scale, so a catalog change moves the art instead of silently
 *  mismatching it. `originY` is how far above the sprite's own top edge the
 *  blit anchor sits (stairs overhang the arrival deck; rooms do not). */
export interface SpriteDef {
  name: string;
  w: number;
  h: number;
  originY: number;
  draw: (ctx: CanvasRenderingContext2D) => void;
}

export const SPRITES: SpriteDef[] = [
  {
    name: "stairFlight",
    w: FACILITIES.stairs.width * TILE, // 88
    h: FLOOR + STAIR.overhang, // 51
    originY: STAIR.overhang, // 7
    draw: (ctx) => drawStairFlight(ctx, 0, FACILITIES.stairs.width * TILE, STAIR.overhang, STAIR.overhang + FLOOR),
  },
  {
    name: "office.leased",
    w: FACILITIES.office.width * TILE, // 99
    h: FLOOR, // 44
    originY: 0,
    draw: (ctx) => office(roomCtx(ctx), sampleUnit("office", "leased"), 0, 0, FACILITIES.office.width * TILE, FLOOR),
  },
  // ... one entry per kind and state you want to freeze
];

Rooms need one entry per visual state, which is where the count gets real:
sampleState() in pixelSprites.ts already enumerates them, and geoVariant()
multiplies each kind by its variant count. Budget for that before committing to
extraction as a project-wide strategy. Stairs have exactly one state, which is
another reason they are the right first target.

For the raster sink in Node, use @napi-rs/canvas (a real 2D context) as a
devDependency. In the browser you can use an OffscreenCanvas and skip the
dependency entirely, driven from the existing gallery page.

Emitting SVG from a pixel buffer

Two levels of effort, both starting from the ImageData.

Greedy rectangle merge. For each distinct color, build a boolean mask, then
repeatedly take the topmost-leftmost unclaimed pixel, extend right while the mask
holds, extend down while the whole row holds, and emit that rectangle. Exact,
about thirty lines, and it collapses those 298 calls to roughly forty or fifty
rects. Group by color under <g fill="..."> so the fill string is not repeated.

Contour tracing. For each color, find connected components and walk their
outlines, emitting a single <path> with only horizontal and vertical segments
(M, H, V, Z). This gets you to roughly ten elements and is the only form a
human can meaningfully edit afterward. Marching squares is the standard approach.
Do not reach for potrace or similar: they fit curves to the staircase edges,
which is the opposite of what you want here.

Whichever you use, set shape-rendering="crispEdges" on the root and keep every
coordinate an integer. That is what preserves the hard pixel look. Removing that
one attribute is what later turns smoothing on, which is the point of the next
section.

The antialiasing question, and what each format forecloses

Worth being precise here, because "raster sprites could be smoothed later" is true
in one sense and misleading in another.

Setting imageSmoothingEnabled = true when upscaling an 88 x 51 PNG does not
antialias it. It bilinearly interpolates it, which reads as blur: soft, muddy
edges with no added definition. Antialiasing means computing coverage from a
higher-fidelity source. You cannot recover fidelity you never had.

So smoothing later requires one of:

  • A vector source. Drop crispEdges and the renderer computes real coverage
    at whatever resolution it rasterizes at, automatically, at every zoom level.
    One attribute.
  • A supersampled raster master. Author at 4x (352 x 204), treat the 1x PNG as
    a derived export, and switch which one you ship. This is real work and doubles
    the authoring burden per sprite.

Note that you cannot supersample your way there from the procedural code either.
ctx.scale(4, 4) on drawStairFlight gives you 4x-chunky pixels, not detail,
because the drawing math is integer-pixel throughout. Extraction in any format
gives you a faithful 1x copy and nothing more. Added detail is authoring work that
happens after extraction, in whatever format you chose.

The practical consequence: SVG keeps the smoothing decision open at the cost of
one attribute. A 1x PNG forecloses it until someone redraws every sprite.
If
smoothing is anywhere on the roadmap, that asymmetry is probably the deciding
factor.

The zoom problem

This one is specific to your camera and it cuts the same way.

pinchTracker.ts computes zoom as dist / this.pinch.dist, and
towerInputCamera.ts clamps it to [effectiveMin, MAX_ZOOM]. Zoom is
continuous, not stepped to integer multiples. A test elsewhere exercises
cam.zoom at 0.06.

Raster sprites are pixel-perfect only at integer scale factors. At continuous zoom
they resample at arbitrary fractional ratios, so you pick your poison: nearest
neighbor gives uneven pixel sizes and shimmer while panning, and bilinear gives
blur. Neither is what pixel art is supposed to look like, and there is no third
option for a 1x raster source.

SVG rasterizes correctly at any scale by definition. The runtime cost is that it
has to be rasterized rather than blitted, but you already cache backing bitmaps
per transport, so the shape of the fix is familiar: quantize zoom into buckets
(eighths, say), rasterize each sprite once per bucket into an offscreen canvas,
and blit from the cache. SVG parsing happens once per bucket, not per frame.

If you want to stay raster, the honest mitigation is to snap zoom to integer
multiples of a base scale. That is a gameplay-feel change, not just a rendering
one, so it needs its own decision.

A third option: a palette-indexed intermediate

Neither SVG nor PNG preserves the thing the current approach is quietly best at,
which is that PAL in pixelSprites/common.ts is a single object you can swap to
retheme the entire game. Bake colors into art and that capability is gone.

A compact indexed text format keeps it:

# stairFlight 88x51 origin=0,7
@ . = transparent
@ s = stringer      # PAL.walnut
@ t = tread         # PAL.white
@ n = nosing        # PAL.glowLit
@ r = rail          # PAL.oak
...........................rrrrrrr..........
..........................rttttttts.........

It is diffable in git, readable in review, and compiles to a PNG, an SVG, or a
draw-call list at build time. Retheming stays a PAL edit because the file stores
roles rather than colors, and the existing RESERVED_COLORS guard test keeps
working unchanged. Artists can still author in Aseprite and export indexed PNG,
with a converter closing the loop in both directions.

The cost is that you are maintaining a format and its toolchain. Only worth it if
extraction becomes project-wide.

Choosing

Procedural (today) 1x raster SVG Indexed intermediate
Correct at continuous zoom yes no yes no (rasterizes to 1x)
Smoothing available later no needs full redraw one attribute needs full redraw
PAL retheme yes no with CSS vars or fill rewrite yes
Diffable in git yes no yes yes
Artist can author it no Aseprite Inkscape Aseprite plus converter
Runtime cost bake once cheapest rasterize per zoom bucket same as raster
Fixes the end-cap class of bug no yes yes yes
Effort to first sprite n/a low medium high

Reading across: raster wins on effort and runtime and loses on everything the
project currently gets for free. SVG costs a caching layer and keeps almost all
of it.

A defensible split, given where you are:

  • Stairs, now: raster. Follow Part 1. It is one sprite, it is the worst
    offender for boundary bugs, and shipping it teaches you what the pipeline
    actually needs to do. Treat it as a spike.
  • Build the Route B extractor next, regardless of which format wins. It is the
    reusable asset, and it makes switching formats later a re-run rather than a
    rewrite.
  • Decide SVG versus raster for room art after the extractor exists, on real
    numbers: measure the rasterization cost per zoom bucket for one office at your
    actual sprite count, and look at a traced SVG in Inkscape to see whether a
    contributor could really work on it.

What would change my recommendation toward SVG for everything: if smoothing is a
near-term goal, or if the continuous-zoom shimmer on raster sprites turns out to
be visible at the zoom levels players actually use. Both are cheap to test before
committing.

Rollback

Keep the procedural painters in the tree behind the extractor for at least one
release after the swap. They are the reference implementation the extraction was
derived from, and if the format choice turns out wrong, regenerating from source
beats reconstructing from exported art.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Read Part 2 of the issue first, then inspect src/render/sprites/transport.ts, src/render/excalibur/towerCrowd.ts, and the proposed src/render/stairGeometry.ts contract. Review scale.test.ts and renderTransport.test.ts before choosing the sprite format and migration scope. Done means the stair geometry remains aligned with climbers, the authored asset loads during boot, and the affected tests and visual baselines pass.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript, vite
Domain
computer-graphics, game-dev
Issue type
Refactor
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.