jam2d
v0.6.0
Published
A tiny, dependency-free pixel game engine for game jams.
Maintainers
Readme
Jam2D
A tiny, dependency-free pixel game engine for game jams.
- Tiny: about 8 kB gzipped, including a built-in pixel font, and no dependencies.
- Pixel-perfect: renders at a low resolution (160×144 by default) and scales up by whole pixels.
- Fast: a WebGL2 sprite batcher that draws a whole screen in a few draw calls.
- Just enough: scenes, animated sprites, text, tilemaps with collision, particles, timers, tweens, keyboard input and a camera.
Install
pnpm add jam2dnpm and yarn work too. Jam2D is written in TypeScript and ships its own types. Any bundler that handles image imports works, such as Vite.
Quick start
A sprite that walks around with the arrow keys:
import { Game, Label, Scene, Sheet, Sprite } from "jam2d";
import heroUrl from "./hero.png";
class Hero extends Sprite<"idle" | "walk"> {
constructor() {
super(
Sheet.from(heroUrl, 8),
{
idle: { frames: [0, 1], frameMS: 400 },
walk: { frames: [2, 3, 4, 5] },
},
"idle",
);
}
update(deltaMS: number): void {
const { input } = this.game;
this.x += input.x * 0.05 * deltaMS;
this.y += input.y * 0.05 * deltaMS;
if (input.x !== 0) this.flip = input.x < 0;
this.play(input.x || input.y ? "walk" : "idle");
}
}
class Level extends Scene {
init(): void {
const hero = this.add(new Hero());
hero.x = this.game.width / 2;
hero.y = this.game.height / 2;
this.add(new Label("arrows to move", { x: 4, y: 4 }));
}
}
const game = new Game();
await game.init({ assets: [heroUrl] });
game.switch(new Level());hero.png is a sprite sheet of 8×8 cells, numbered left to right, top to bottom.
Core ideas
Game
Game creates the canvas, loads your images and runs the game loop.
await game.init({
width: 160, // game resolution, in pixels
height: 144,
background: 0x49a269,
assets: [heroUrl, tilesUrl],
});Every image your game uses must be listed in assets, so it's ready before the first scene starts. game.width, game.height and game.input are available everywhere.
Scene
A scene is one screen or level. Build it in init(), and clean up in deinit() if you need to. game.switch(scene) changes scenes between frames.
class Level extends Scene {
init(): void {
this.add(new Hero());
}
update(): void {
const hero = this.all(Hero)[0];
this.camera.x = hero.x - this.game.width / 2;
}
}add(entity)puts an entity in the scene, andremove(entity)takes it out. A removed entity stops updating and drawing right away.all(Type)returns every entity of that class.camerascrolls the world. Each entity'sscrollsets how much the camera moves it: 1 by default, 0 for a HUD, and something like 0.5 for a parallax background. Collision ignoresscroll, so keep anything solid at 1.timersbelong to the scene, and stop when you switch away.reset()is used by the engine when you switch away, so don't define your ownresetin a scene.
Entity
The base of everything in a scene: Sprite, Label, Tilemap and Particles all extend it. Every entity has:
x,y,layer,visibleandscroll. Positions can be fractional: drawing rounds them to whole pixels, so there's no sub-pixel shimmer.tint, which multiplies its colors.0xffffff(the default) leaves them unchanged,0xff8080makes a sprite redder for a hit flash, and0x808080darkens a background tilemap. A label's tint is its text color.update(deltaMS), called every frameinit()anddeinit(), called when it's added to or removed from a scenetimers, which stop when the entity is removedthis.sceneandthis.game
Draw order: entities draw by layer, lowest first. Within a layer, they draw in the order they were added. Put a tilemap on a lower layer than your sprites to keep it behind them.
Update order: each frame, every entity runs its timers and then its update, in draw order: by layer, then in the order they were added. Then the scene runs its timers and its update.
Sprite
An animated image from a sprite sheet, positioned by its center.
play(state)switches animation. Each state lists sheet cells andframeMS, how long each frame shows (default 100). Playing the state that's already playing does nothing, unless you passtrueto start it again from the first frame:play("hop", true).loop: falseon a state plays it once and holds the last frame.doneturns true when it ends:if (this.state === "attack" && this.done) this.play("idle");duration(animation)is how long one pass of an animation takes, in ms. Use it to time a tween to an animation.flipmirrors it horizontally.bodyis its hitbox, aRectrelative to its center. By default it's the size of one cell.
Label
Text in the built-in monogram pixel font. Each character is 6×12 pixels, and \n starts a new line.
Label.defaultOptions.tint = 0xa2ffcb; // default color for every label
const score = this.add(new Label("score: 0", { x: 4, y: 4, scroll: 0 }));
score.text = "score: 10";width and height give its size in pixels, so centering is one line: label.x = (this.game.width - label.width) / 2.
For your own font, pass a Sheet as font. Its cells must be in ASCII order starting with space (32), left to right then top to bottom, at the cell size you give the sheet.
Label.defaultOptions.font = Sheet.from("tiny-font.png", 4, 6);Tilemap
A level drawn with ASCII art. legend maps characters to sheet cells, or to an animation like a sprite's, and solid lists the characters that block movement.
this.add(
new Tilemap(
Sheet.from(tilesUrl, 8),
[
"##########",
"#........#",
"#..^^.~~~#",
"##########",
],
{
legend: { "#": 1, "^": 2, "~": { frames: [3, 4, 5], frameMS: 250 } },
solid: "#",
},
),
);Animated tiles with the same character stay in sync.
Move sprites through it so walls stop them:
const level = this.scene.all(Tilemap)[0];
level.moveX(this, dx); // returns true if a wall stopped it
level.moveY(this, dy);
if (level.touches(this, "^")) this.die(); // any character works as a triggerat(x, y) returns the character at a point, and set(x, y, char) changes it, for coins, breakable blocks and doors. Collision and drawing follow the change, and a character that isn't in legend leaves an empty tile. Move a tilemap with x and y; collision moves with it.
Particles
One entity that draws a whole pool of particles, for dust, sparks and splashes. It allocates nothing after it's created.
const dust = this.add(new Particles(Sheet.from(worldUrl, 8), {
frames: [13, 14, 15, 16], // played once over each particle's life
life: 400, // ms
dx: [-0.04, 0.04], // px/ms, random between min and max
dy: [-0.05, -0.03],
ddy: 0.0002, // px/ms², added to dy every ms
}));
dust.emit(x, y, 6);emit(x, y, count)takes a world position. Particles stay where they were emitted, so the entity's ownxandyaren't used.- Each particle starts with a random
dxanddyfrom its range, andddxandddychange them over time. All four default to 0. framesplay once over each particle'slife. Particles can't fade out in code, so draw the fade into the frames. Semi-transparent pixels in the image do blend.- Particles aren't solid, and go through tilemaps and sprites.
- At most
capacityparticles (default 256) are alive at once; extra ones are dropped.countis how many are alive, andclear()removes them all. - For a steady stream, use a timer:
timers.every(50, () => dust.emit(x, y)).
Collision
collide(solid, mover) pushes mover out of solid, and returns which side of the mover hit: "left", "right", "top", "bottom", or null.
for (const crate of this.scene.all(Crate)) {
if (collide(crate, this, level) === "bottom") this.grounded = true;
}Pass the tilemap as the third argument so the push never goes into a wall.
Input
Retro console-style controls (a d-pad plus A, B, Start and Select) and a pointer, read with game.input:
| Control | Keys |
| -------------------------- | -------------------------- |
| left right up down | arrow keys or WASD |
| a | Z or Space |
| b | X |
| start | Enter |
| select | Shift |
| click | left mouse button or touch |
held(control)is true while it's down.pressed(control)andreleased(control)are true on the frame it changes.xandyare -1, 0 or 1 for the direction keys.pointer.xandpointer.yare the mouse or touch position in game pixels on screen. Addscene.camerafor a world position.
Timers
this.timers.after(1000, () => this.explode());
const stop = this.timers.every(250, () => (this.visible = !this.visible));
stop(); // cancels a timerTweens move number fields to new values over time:
const stop = this.timers.tween(this, { x: 64, y: 32 }, 400);
this.timers.tween(this, { y: this.y - 8 }, 300, {
ease: "out",
done: () => this.scene.remove(this),
});- The target can be any object with number fields: an entity,
scene.cameraor a plain object such as{ t: 0 }. Colors liketintdon't tween directly; tween a{ t }and usemix. - Start values are read when you call
tween. When it ends, the fields land exactly on the end values, thendoneruns. stop()freezes the fields where they are, anddonedoesn't run.easeis"linear"(the default),"in","out","inOut","back"(overshoots, then settles) or"bounce". A function from 0–1 to a value works too, andeaseexports the built-in ones for your own math:ease.out(0.5).- Tweens on the same field all run, and the one that updates last wins each frame: within one
timers, the newest, and a scene tween beats an entity tween (see update order). To replace a tween, call itsstop()first. - To delay or chain tweens, use
afteranddone.
Scene transitions. Only one scene runs at a time, so a transition is an "out" in the old scene, then switch(), then an "in" in the new scene's init.
Fade to black: tween a plain
{ t }, and each frame usemixto darken every entity's tint and the background. Fade the new scene in the same way, withtgoing from 1 to 0.private fade = { t: 0 }; leave(next: Scene): void { this.timers.tween(this.fade, { t: 1 }, 300, { done: () => this.game.switch(next), }); } update(): void { const { t } = this.fade; const tint = mix(0xffffff, 0x000000, t); for (const entity of this.entities) entity.tint = tint; this.game.background = mix(BACKGROUND, 0x000000, t); }This replaces tints the game has set. To keep a label's color, mix from that color instead of white.
Slide in: in the new scene's
init, setthis.camera.x = -this.game.widthand tween it to 0. Entities withscroll: 0stay still.
timers.elapsed is how long, in ms, an entity or scene has been running. To animate your own entities, frameAt(animation, timers.elapsed) returns the sheet cell to draw. To start an animation from its first frame, save timers.elapsed when it starts and pass timers.elapsed - start instead.
Helpers
lerp(0, 10, 0.5); // 5
clamp(player.x, 0, 160);
random(-1, 1); // any number from -1 up to, but not including, 1
randomInt(1, 6); // a whole number from 1 to 6, both included
mix(0xff0000, 0x0000ff, 0.5); // 0x800080, halfway between two colorsmix makes colors tweenable: tween a plain { t } and set tint = mix(from, to, t) each frame, for example to fade a hit flash back to normal.
Examples
The repo includes a few small examples. Start with basic, which is the Quick start above, then try platformer, snake, falldown and memory. To open them:
pnpm install
pnpm devBrowser support
Any browser with WebGL2, which covers all current desktop and mobile browsers.
Credits
- monogram by datagoblin - the built-in font (CC0).
License
MIT
