npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

jam2d

v0.6.0

Published

A tiny, dependency-free pixel game engine for game jams.

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 jam2d

npm 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, and remove(entity) takes it out. A removed entity stops updating and drawing right away.
  • all(Type) returns every entity of that class.
  • camera scrolls the world. Each entity's scroll sets how much the camera moves it: 1 by default, 0 for a HUD, and something like 0.5 for a parallax background. Collision ignores scroll, so keep anything solid at 1.
  • timers belong to the scene, and stop when you switch away.
  • reset() is used by the engine when you switch away, so don't define your own reset in a scene.

Entity

The base of everything in a scene: Sprite, Label, Tilemap and Particles all extend it. Every entity has:

  • x, y, layer, visible and scroll. 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, 0xff8080 makes a sprite redder for a hit flash, and 0x808080 darkens a background tilemap. A label's tint is its text color.
  • update(deltaMS), called every frame
  • init() and deinit(), called when it's added to or removed from a scene
  • timers, which stop when the entity is removed
  • this.scene and this.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 and frameMS, how long each frame shows (default 100). Playing the state that's already playing does nothing, unless you pass true to start it again from the first frame: play("hop", true).

  • loop: false on a state plays it once and holds the last frame. done turns 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.

  • flip mirrors it horizontally.

  • body is its hitbox, a Rect relative 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 trigger

at(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 own x and y aren't used.
  • Each particle starts with a random dx and dy from its range, and ddx and ddy change them over time. All four default to 0.
  • frames play once over each particle's life. 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 capacity particles (default 256) are alive at once; extra ones are dropped. count is how many are alive, and clear() 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) and released(control) are true on the frame it changes.
  • x and y are -1, 0 or 1 for the direction keys.
  • pointer.x and pointer.y are the mouse or touch position in game pixels on screen. Add scene.camera for a world position.

Timers

this.timers.after(1000, () => this.explode());
const stop = this.timers.every(250, () => (this.visible = !this.visible));
stop(); // cancels a timer

Tweens 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.camera or a plain object such as { t: 0 }. Colors like tint don't tween directly; tween a { t } and use mix.
  • Start values are read when you call tween. When it ends, the fields land exactly on the end values, then done runs.
  • stop() freezes the fields where they are, and done doesn't run.
  • ease is "linear" (the default), "in", "out", "inOut", "back" (overshoots, then settles) or "bounce". A function from 0–1 to a value works too, and ease exports 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 its stop() first.
  • To delay or chain tweens, use after and done.

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 use mix to darken every entity's tint and the background. Fade the new scene in the same way, with t going 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, set this.camera.x = -this.game.width and tween it to 0. Entities with scroll: 0 stay 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 colors

mix 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 dev

Browser support

Any browser with WebGL2, which covers all current desktop and mobile browsers.

Credits

  • monogram by datagoblin - the built-in font (CC0).

License

MIT