Yodacon Ship Pack · Tutorial Note 1 · October 2026
From Sprite Sheet to Moving Ship: Starting a Game With the Yodacon Ship Pack
Abstract—The Yodacon Ship Pack publishes the starships of the 1997–1998 ConEx Escape Velocity plugins as GPL-licensed PNG sprite sheets with a JSON description of each ship. This note shows a newcomer how to start a project with them: where the files are, the one convention that governs every sprite, the three functions that turn a heading into pixels on screen, and how to carry those three functions into an engine of their own. Every listing is taken from, and cites, the pack's own code.
Index Terms—sprite sheets, 2-D rendering, game loops, pixel art, asset pipelines.
I. Introduction
A ship in a 1990s top-down space game is not rotated at run time. It is drawn 36 times in advance, once per 10° of heading, and the game shows whichever drawing is closest to the way the ship points. The pack keeps that design [1]. Rendering a ship is therefore not image rotation but frame selection: compute one integer from the heading, find that frame's rectangle in a sheet, and copy the rectangle to the screen. The rest of this note builds those steps (Section III), puts them in a game loop (Section IV), moves them to other engines (Section V), and adds your own content (Section VI).
II. What Is in the Pack
Each ship lives in a folder <edition>/ships/<slug>/, where the edition is
conex (1997), override (1998) or konex. Table I lists the files.
catalog.json at the root lists every edition and ship, and is the only file a program needs to
discover the rest [2].
| File | Use |
|---|---|
sheet-alpha.png | 36 frames, 6 × 6, transparent; the file to draw from |
sheet.png, mask.png | The 1997 opaque sheet and its 1-bit mask |
frames/NN.png | The same 36 frames as single files, 00–35 |
target, yard, comm, thumb | Targeting, shipyard, comm and thumbnail pictures |
ship.json | Name, frame size, file names and the 1997 statistics |
The part of ship.json a renderer needs is the sprite object (Listing 1). Frame sizes
differ between ships (36 to 120 pixels), so never hard-code them; read them here.
sprite block of conex/ships/dart/ship.json"sprite": { "frame_width": 80, "frame_height": 80,
"columns": 6, "rows": 6, "frames": 36,
"rotation": "frame 00 nose-up, 10 degrees
clockwise per frame" }
III. The Core: Heading to Pixels
A. The Convention
Heading θ is measured in degrees clockwise from straight up, which matches screen coordinates whose y axis points down. Frame 00 is nose-up and each following frame is 10° further clockwise [3, lines 12–17]. The frame to show and its place in the sheet follow directly:
f = ⌊θ / 10⌋ mod 36(1)
x = (f mod 6) · w, y = ⌊f / 6⌋ · h(2)
where w and h are frame_width and frame_height. The modulo in (1) must
wrap negative headings too, which JavaScript's % does not; Listing 2 corrects for it.
lib/yodacon-ship-pack.js:115 and :131export function frameIndex(headingDeg) {
const i = Math.floor(headingDeg / 10) % FRAMES;
return i < 0 ? i + FRAMES : i; // -10° → 35
}
export function frameRect(ship, i) {
const { w, h, columns } = spriteOf(ship);
const f = frameIndex(i * 10);
return { x: (f % columns) * w, // (2)
y: Math.floor(f / columns) * h, w, h };
}
B. Loading
A ship is its ship.json plus one decoded image. loadShip() fetches both
(Listing 3) and caches the image, so ten ships of one kind cost one download [3, line 56].
lib/yodacon-ship-pack.js:156export async function loadShip(edition = "conex",
slug = "yodacon", base = PACK_BASE) {
const ship = await loadShipMeta(edition, slug,
base, "ships");
ship.sheet = await loadImage(ship.urls.sheet);
return ship;
}
C. Drawing
Drawing copies the rectangle from (2) to a point centred on the ship (Listing 4). Two details matter for 1997 pixel art: turn off image smoothing, and round the destination to whole pixels; scale only by whole numbers, or the pixels smear.
lib/yodacon-ship-pack.js:182export function drawShip(ctx, ship, headingDeg,
x, y, scale = 1) {
const r = frameRect(ship, frameIndex(headingDeg));
const w = r.w * scale, h = r.h * scale;
ctx.imageSmoothingEnabled = false; // crisp pixels
ctx.drawImage(ship.sheet, r.x, r.y, r.w, r.h,
Math.round(x - w / 2), Math.round(y - h / 2), w, h);
}
IV. A First Game Loop
With Listings 2–4, a flyable ship is a page and some 40 lines. Create index.html beside a copy of
the pack (or import the library from yodacon.org or jsDelivr [3, lines 6–8]) and serve the folder with any static
server, for example python3 -m http.server; ES modules do not load from file://.
Listing 5 is the loop of the Canvas example [4]: turn with the arrow keys, thrust along the
frame shown so motion matches the picture, integrate by the frame time, and draw.
examples/canvas/index.html:34–84import { loadShip, drawShip }
from "./yodacon-ship-pack/lib/yodacon-ship-pack.js";
const ship = await loadShip("conex", "dart");
const s = { x: 380, y: 260, vx: 0, vy: 0, heading: 0 };
const keys = {};
onkeydown = (e) => keys[e.key] = true;
onkeyup = (e) => keys[e.key] = false;
let last = performance.now();
requestAnimationFrame(function frame(now) {
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
if (keys.ArrowLeft) s.heading -= 180 * dt;
if (keys.ArrowRight) s.heading += 180 * dt;
s.heading = (s.heading + 360) % 360;
if (keys.ArrowUp) { // along the frame shown
const a = Math.floor(s.heading / 10) * 10
* Math.PI / 180;
s.vx += Math.sin(a) * 200 * dt; // x: sin θ
s.vy -= Math.cos(a) * 200 * dt; // y: −cos θ
}
s.x += s.vx * dt; s.y += s.vy * dt;
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawShip(ctx, ship, s.heading, s.x, s.y, 1);
requestAnimationFrame(frame);
});
The unit vector for heading θ under this convention is (sin θ, −cos θ). The demo and the flying games in
projects/ (Orbital Defence, Slingshot, ConEx Courier) use it, so they are worked examples of the same loop.
V. Moving to Another Engine
Each example page ends with its own note, Tutorial Notes 2–10 [8]–[16], which walks through that page's
source line by line: the DOM and CSS sprite, Canvas 2D, the <yodacon-ship> element,
three.js, Phaser 3, PixiJS, SVG, CSS with no script, and a worker with OffscreenCanvas. This section gives the pattern they share.
Nothing above depends on the browser except drawImage. An engine needs three things: the sheet
as a texture, the frame grid from Listing 1, and Equation (1). Do not also rotate the sprite; the
frame already shows the rotation.
A. Phaser
Phaser slices the sheet itself when given the frame size, and the frame number is (1) [5], [12]:
examples/phaser/index.html:45 and :71–72this.load.spritesheet("ship", ship.urls.sheet,
{ frameWidth, frameHeight });
// each update:
this.ship.setFrame(frameIndex(this.heading));
B. Godot 4
A Sprite2D with the sheet as its texture slices a grid through hframes and
vframes; the frame is (1) again. Turn off filtering on the texture for crisp pixels.
Sprite2Dextends Sprite2D
var heading := 0.0 # degrees clockwise from up
func _ready():
texture = load("res://ships/dart/sheet-alpha.png")
hframes = 6; vframes = 6
texture_filter = TEXTURE_FILTER_NEAREST
func _process(delta):
heading = fposmod(heading + Input.get_axis(
"ui_left", "ui_right") * 180.0 * delta, 360.0)
frame = int(heading / 10.0) % 36
C. Any Other Engine
In three.js, pygame, raylib, SDL, Unity or Bevy the pattern is the same: load sheet-alpha.png,
compute the source rectangle with (2), and draw it. In UV terms the rectangle is
u = (f mod 6)/6, v = ⌊f/6⌋/6 with size 1/6 × 1/6, flipping v in engines whose
texture origin is the bottom left [6]. If an engine cannot take a sheet, use the single files
frames/NN.png and index them by f.
VI. Building On It
A. Finding Ships
Read catalog.json instead of hard-coding slugs, so new ships appear in your game when the pack
grows. loadCatalog() [3, line 77] returns every edition with every ship's file URLs.
B. Using the 1997 Numbers
ship.json keeps each ship's original Escape Velocity statistics under stats:
Speed, Accel, Maneuver, Shield, Armor,
Holds and more. Scale them into your units rather than inventing new ones; Fleet Tactics and ConEx
Courier in projects/ show two such mappings in their READMEs.
C. Licence and Credit
The art is GPL-2.0-or-later. Keep the credit line (CREDIT in the library) in your game, and if you
ship it, add it to the pack's leaderboard through USED-IN.md [7].
VII. Conclusion
Rendering a pre-rotated sprite comes down to Equation (1), Equation (2) and one copy of a rectangle. Those three steps are about a dozen lines in the pack's library and carry unchanged into any engine. Start from Listing 5, swap in your engine's draw call, and the 1997 fleet is flying in your project.
References
- Yodacon Ship Pack, “README,” 2026. [Online]. Available: github.com/yodacon/yodacon-ship-pack
- Yodacon Ship Pack, catalog.json, v2.0.0, 2026.
- Yodacon Ship Pack, lib/yodacon-ship-pack.js, v2.0.0, 2026.
- Yodacon Ship Pack, “Canvas 2D example,” examples/canvas/, 2026.
- Yodacon Ship Pack, “Phaser example,” examples/phaser/, Phaser 3.80.1, 2026.
- Yodacon Ship Pack, “three.js example,” examples/threejs/, three.js 0.160.0, 2026.
- Yodacon Ship Pack, USED-IN.md and CONTRIBUTING.md, 2026.
- Yodacon Ship Pack, “A ship in plain HTML and CSS,” Tutorial Note 2, examples/dom/, 2026.
- Yodacon Ship Pack, “A game loop on Canvas 2D,” Tutorial Note 3, examples/canvas/, 2026.
- Yodacon Ship Pack, “Ships as HTML: the yodacon-ship element,” Tutorial Note 4, examples/web-component/, 2026.
- Yodacon Ship Pack, “Pre-rotated sprites in 3-D,” Tutorial Note 5, examples/threejs/, 2026.
- Yodacon Ship Pack, “One scene, two assets: the Phaser 3 example,” Tutorial Note 6, examples/phaser/, 2026.
- Yodacon Ship Pack, “Thirty-six textures on one image,” Tutorial Note 7, examples/pixi/, 2026.
- Yodacon Ship Pack, “A viewport per ship: the SVG example,” Tutorial Note 8, examples/svg/, 2026.
- Yodacon Ship Pack, “Spinning without script: the CSS-only example,” Tutorial Note 9, examples/css/, 2026.
- Yodacon Ship Pack, “Drawing off the main thread: the OffscreenCanvas example,” Tutorial Note 10, examples/offscreen/, 2026.