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

light-engine-procedural

v0.0.4

Published

`light-engine-procedural` is a library project with light engine written in procedural style. It is rewritten from light-engine-oop.

Readme

Light Engine Procedural

light-engine-procedural is a library project with light engine written in procedural style.
It is rewritten from light-engine-oop.

Table of Contents

Assumptions

High Level

AI Prompt:

I want to write a simple 2D game engine for a PC browser.
Canvas on full screen. It fits the window. Animations.
Updates for physics and renders for graphics. Input system.
Music and sounds. That's all, procedurally. Divided into modules.
So that the game itself can be added to this framework. Do you understand?
The config is state-of-the-art, minimal. The script builds everything and hosts it.
Typescipt, html, styles, and code are all separate files.

⬆ Table of Contents

Requirements

A minimal, modern 2D engine skeleton in TypeScript for PC browsers, based on Canvas (fullscreen, responsive), with:

  • a game loop (update + render, separated),
  • animations,
  • input system (keyboard / mouse),
  • audio (music + SFX),
  • modular structure,
  • clean architecture (engine separated from game logic),
  • modern build system (Vite / ES modules),
  • separate files: HTML, CSS, TS,
  • a dev script → builds and hosts.

Basically, something like a lightweight, custom mini-Unity / Phaser, but procedural and minimalist.

⬆ Table of Contents

🔥 Architecture

/src
  core.ts
  loop.ts
  renderer.ts
  input.ts
  audio.ts
  index.ts
  oop/
  script/setup.ps1
package.json
tsconfig.json
vite.config.ts
.gitignore
.env
.npmrc

Files in src will be procedural style.
oop folder will contain oop wrappers.

Script

script/setup.ps1

$base = "/home/atari-monk/atari-monk/project/light-engine-procedural"

$folders = @(
    "$base/src",
    "$base/src/oop"
)

foreach ($folder in $folders) {
    if (-not (Test-Path $folder)) {
        New-Item -ItemType Directory -Path $folder | Out-Null
        Write-Host "Created folder: $folder"
    }
    else {
        Write-Host "Folder already exists: $folder"
    }
}

$files = @(
    "$base/package.json",
    "$base/tsconfig.json",
    "$base/vite.config.ts",
    "$base/.gitignore",
    "$base/.env",
    "$base/.npmrc",

    "$base/src/core.ts",
    "$base/src/loop.ts",
    "$base/src/renderer.ts",
    "$base/src/input.ts",
    "$base/src/audio.ts",

    "$base/src/oop/loop.ts",
    "$base/src/oop/renderer.ts",
    "$base/src/oop/input.ts",
    "$base/src/oop/audio.ts"
)

foreach ($file in $files) {
    if (-not (Test-Path $file)) {
        New-Item -ItemType File -Path $file | Out-Null
        Write-Host "Created file: $file"
    }
    else {
        Write-Host "File already exists: $file"
    }
}

Write-Host "light-engine-procedural structure check complete."

⬆ Table of Contents

🚀 Stack

(the simplest and modern)

  • TypeScript
  • Vite (dev server + build)
  • ES modules
  • Zero frameworks

⬆ Table of Contents

Project

📦 Installation

pnpm create vite@latest light-engine-procedural -- --template vanilla-ts
cd light-engine-procedural
pnpm install
pnpm run dev

There is a need to remove template files not needed for library.
This project was copied from light-engine-oop so create was not used.

⬆ Table of Contents

⚙️ Configuration

Typescript Config

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "useDefineForClassFields": true,
    "module": "ESNext",
    "lib": [
      "ES2022",
      "DOM",
      "DOM.Iterable"
    ],
    "types": [
      "vite/client"
    ],
    "skipLibCheck": true,
    /* Bundler mode */
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": false,
    "verbatimModuleSyntax": true,
    "moduleDetection": "force",
    "noEmit": false,
    "declaration": true,
    "outDir": "dist",
    "declarationDir": "dist",
    "emitDeclarationOnly": true,
    "rootDir": "src",
    /* Linting */
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "erasableSyntaxOnly": false,
    "noFallthroughCasesInSwitch": true,
    "noUncheckedSideEffectImports": true
  },
  "include": [
    "src"
  ]
}

Vite handles bundling.

Package Config

package.json

{
  "name": "light-engine-procedural",
  "version": "0.0.1",
  "main": "dist/index.cjs.js",
  "module": "dist/index.esm.js",
  "types": "dist/index.d.ts",
  "files": [
    "dist"
  ],
  "exports": {
    ".": {
      "import": "./dist/light-engine-procedural.es.js",
      "require": "./dist/light-engine-procedural.cjs.js",
      "types": "./dist/index.d.ts"
    }
  },
  "scripts": {
    "build": "vite build && tsc --emitDeclarationOnly",
    "dev": "vite"
  },
  "devDependencies": {
    "typescript": "^5.9.3",
    "vite": "^8.0.0-beta.16"
  }
}

Vite Config

vite.config.ts

import { defineConfig } from "vite";

export default defineConfig({
    build: {
        lib: {
            entry: "./src/index.ts",
            name: "LightEngineProcedural",
            fileName: (format) => `light-engine-procedural.${format}.js`,
            formats: ["es", "cjs", "umd"]
        },
        minify: true,
        sourcemap: true,
        rollupOptions: {
            external: [],
        }
    }
});

Git Config

.gitignore

# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*

node_modules
dist
dist-ssr
*.local

# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.env

⬆ Table of Contents

Engine

Game Loop

Classic fixed update + render.
✔ constant interval physics
✔ smooth render

loop.ts

export interface LoopState {
  lastTime: number;
  accumulator: number;
  fixedDelta: number;
  update: (dt: number) => void;
  render: (alpha: number) => void;
  running: boolean;
  frameId: number | null;
}

export function createLoop(
  update: (dt: number) => void,
  render: (alpha: number) => void,
  fixedDelta: number = 1 / 60
): LoopState {
  return {
    lastTime: 0,
    accumulator: 0,
    fixedDelta,
    update,
    render,
    running: false,
    frameId: null,
  };
}

export function startLoop(state: LoopState) {
  if (state.running) return;
  state.running = true;
  state.frameId = requestAnimationFrame((time) => tick(state, time));
}

export function stopLoop(state: LoopState) {
  if (!state.running) return;
  state.running = false;
  if (state.frameId !== null) {
    cancelAnimationFrame(state.frameId);
    state.frameId = null;
  }
}

function tick(state: LoopState, time: number) {
  if (!state.running) return;

  if (!state.lastTime) {
    state.lastTime = time;
  }

  const delta = Math.min((time - state.lastTime) / 1000, 0.25);
  state.lastTime = time;
  state.accumulator += delta;

  while (state.accumulator >= state.fixedDelta) {
    state.update(state.fixedDelta);
    state.accumulator -= state.fixedDelta;
  }

  const alpha = state.accumulator / state.fixedDelta;
  state.render(alpha);

  state.frameId = requestAnimationFrame((t) => tick(state, t));
}

oop/loop.ts

import { LoopState, createLoop, startLoop, stopLoop } from "./../loop";

export class GameLoop {
  private state: LoopState;

  constructor(
    update: (dt: number) => void,
    render: (alpha: number) => void,
    fixedDelta: number = 1 / 60
  ) {
    this.state = createLoop(update, render, fixedDelta);
  }

  start() {
    startLoop(this.state);
  }

  stop() {
    stopLoop(this.state);
  }

  isRunning(): boolean {
    return this.state.running;
  }
}

⬆ Table of Contents

Renderer

Simple fullscreen canvas renderer with resize handling.
✔ auto-resize
✔ context management
✔ clear utility

src/renderer.ts

interface RenderState {
    canvas: HTMLCanvasElement;
    ctx: CanvasRenderingContext2D;
}

function createRenderState(canvasId: string): RenderState {
    const canvas = document.getElementById(canvasId) as HTMLCanvasElement;
    if (!canvas) throw new Error("Canvas not found!");

    const ctx = canvas.getContext("2d")!;
    const state: RenderState = { canvas, ctx };

    resize(state);
    window.addEventListener("resize", () => resize(state));

    return state;
}

function resize(state: RenderState) {
    const dpr = window.devicePixelRatio || 1;
    state.canvas.width = window.innerWidth * dpr;
    state.canvas.height = window.innerHeight * dpr;
    state.canvas.style.width = window.innerWidth + "px";
    state.canvas.style.height = window.innerHeight + "px";
    state.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}

function clear(state: RenderState) {
    state.ctx.clearRect(0, 0, state.canvas.width, state.canvas.height);
}

function drawCircle(state: RenderState, x: number, y: number, radius: number, color: string) {
    state.ctx.fillStyle = color;
    state.ctx.beginPath();
    state.ctx.arc(x, y, radius, 0, Math.PI * 2);
    state.ctx.fill();
}

function drawRect(state: RenderState, x: number, y: number, width: number, height: number, color: string) {
    state.ctx.fillStyle = color;
    state.ctx.fillRect(x, y, width, height);
}

function drawLine(state: RenderState, x1: number, y1: number, x2: number, y2: number, color: string, lineWidth = 1) {
    state.ctx.strokeStyle = color;
    state.ctx.lineWidth = lineWidth;
    state.ctx.beginPath();
    state.ctx.moveTo(x1, y1);
    state.ctx.lineTo(x2, y2);
    state.ctx.stroke();
}

function drawText(state: RenderState, text: string, x: number, y: number, color: string, font = "16px sans-serif") {
    state.ctx.fillStyle = color;
    state.ctx.font = font;
    state.ctx.fillText(text, x, y);
}

src/oop/renderer.ts

export class Renderer {
    private state: RenderState;

    constructor(canvasId: string) {
        this.state = createRenderState(canvasId);
    }

    resize() {
        resize(this.state);
    }

    clear() {
        clear(this.state);
    }

    drawCircle(x: number, y: number, radius: number, color: string) {
        drawCircle(this.state, x, y, radius, color);
    }

    drawRect(x: number, y: number, width: number, height: number, color: string) {
        drawRect(this.state, x, y, width, height, color);
    }

    drawLine(x1: number, y1: number, x2: number, y2: number, color: string, lineWidth = 1) {
        drawLine(this.state, x1, y1, x2, y2, color, lineWidth);
    }

    drawText(text: string, x: number, y: number, color: string, font?: string) {
        drawText(this.state, text, x, y, color, font);
    }

    get ctx() {
        return this.state.ctx;
    }

    get canvas() {
        return this.state.canvas;
    }
}

⬆ Table of Contents

Input

Simple keyboard input handling with Set.
✔ key tracking
✔ clean API
✔ lightweight

input.ts

export const KEYS = ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"] as const;
export type Key = typeof KEYS[number];

export type InputState = {
  keys: Set<Key>;
};

export function createInputState(): InputState {
  return {
    keys: new Set()
  };
}

export function attachInput(state: InputState) {
  window.addEventListener("keydown", (e) => {
    if (KEYS.includes(e.key as Key)) {
      e.preventDefault();
      state.keys.add(e.key as Key);
    }
  });

  window.addEventListener("keyup", (e) => {
    if (KEYS.includes(e.key as Key)) {
      state.keys.delete(e.key as Key);
    }
  });
}

export function isKeyDown(state: InputState, key: Key): boolean {
  return state.keys.has(key);
}

src/oop/input.ts

export class Input {
  private state: InputState;

  constructor() {
    this.state = createInputState();
    attachInput(this.state);
  }

  isDown(key: Key): boolean {
    return isKeyDown(this.state, key);
  }
}

⬆ Table of Contents

Audio

  • Minimal Web Audio manager with looping music support and volume control
    • SFX supportload() + play() for short sounds.
    • Looping musicplayMusic() with optional volume.
    • Volume controlsetMusicVolume().
    • Safe browser autoplay handling – resumes AudioContext on first user interaction.
    • Single music track at a time – clean and minimal.
    • Fully modular, fits your engine architecture.

audio.ts

export type AudioState = {
    context: AudioContext;
    buffers: Map<string, AudioBuffer>;
    unlocked: boolean;
    musicSource: AudioBufferSourceNode | null;
    musicGain: GainNode;
};

export function createAudioState(): AudioState {
    const context = new AudioContext();
    const musicGain = context.createGain();
    musicGain.connect(context.destination);

    const state: AudioState = {
        context,
        buffers: new Map(),
        unlocked: false,
        musicSource: null,
        musicGain,
    };

    const unlock = async () => {
        if (state.context.state === "suspended") {
            await state.context.resume();
        }
        state.unlocked = true;
        window.removeEventListener("click", unlock);
        window.removeEventListener("keydown", unlock);
    };

    window.addEventListener("click", unlock, { once: true });
    window.addEventListener("keydown", unlock, { once: true });

    return state;
}

export async function loadAudio(
    state: AudioState,
    name: string,
    url: string
) {
    const res = await fetch(url);
    const arrayBuffer = await res.arrayBuffer();
    const audioBuffer = await state.context.decodeAudioData(arrayBuffer);
    state.buffers.set(name, audioBuffer);
}

export function playSound(state: AudioState, name: string) {
    if (!state.unlocked) return;

    const buffer = state.buffers.get(name);
    if (!buffer) return;

    const source = state.context.createBufferSource();
    source.buffer = buffer;
    source.connect(state.context.destination);
    source.start();
}

export function playMusic(
    state: AudioState,
    name: string,
    volume = 1
) {
    if (!state.unlocked) return;

    const buffer = state.buffers.get(name);
    if (!buffer) return;

    stopMusic(state);

    state.musicGain.gain.value = volume;

    const source = state.context.createBufferSource();
    source.buffer = buffer;
    source.loop = true;
    source.connect(state.musicGain);
    source.start();

    state.musicSource = source;
}

export function stopMusic(state: AudioState) {
    if (!state.musicSource) return;

    state.musicSource.stop();
    state.musicSource.disconnect();
    state.musicSource = null;
}

export function setMusicVolume(
    state: AudioState,
    volume: number
) {
    state.musicGain.gain.value = volume;
}

export async function playMusicAfterGesture(
    state: AudioState,
    name: string,
    volume = 1
) {
    if (state.context.state === "suspended") {
        await state.context.resume();
    }

    state.unlocked = true;
    playMusic(state, name, volume);
}

src/oop/audio.ts

import {
    AudioState,
    createAudioState,
    loadAudio,
    playSound,
    playMusic,
    stopMusic,
    setMusicVolume,
    playMusicAfterGesture,
} from "./audio-procedural";

export class Audio {
    private state: AudioState;

    constructor() {
        this.state = createAudioState();
    }

    async load(name: string, url: string) {
        return loadAudio(this.state, name, url);
    }

    play(name: string) {
        playSound(this.state, name);
    }

    playMusic(name: string, volume = 1) {
        playMusic(this.state, name, volume);
    }

    stopMusic() {
        stopMusic(this.state);
    }

    setMusicVolume(volume: number) {
        setMusicVolume(this.state, volume);
    }

    async playMusicAfterGesture(name: string, volume = 1) {
        return playMusicAfterGesture(this.state, name, volume);
    }

    get unlocked() {
        return this.state.unlocked;
    }
}

⬆ Table of Contents

Game

Simple contract between engine and game.
✔ clear separation
✔ dependency injection
✔ minimal API

src/core.ts

export interface IGame {
  update(dt: number): void;
  render(): void;
}

⬆ Table of Contents

Index

index.ts

export type { IGame } from "./core";

export { type LoopState, createLoop, startLoop, stopLoop } from "./loop"
export { type RenderState, createRenderState, resize, clear, drawCircle, drawRect, drawLine, drawText } from './renderer'
export { type InputState, createInputState, attachInput, isKeyDown } from "./input"
export { type AudioState, createAudioState, loadAudio, playSound, playMusic, stopMusic, setMusicVolume, playMusicAfterGesture } from './audio'

export { GameLoop } from "./oop/loop";
export { Renderer } from "./oop/renderer";
export { Input } from "./oop/input";
export { Audio } from "./oop/audio";

⬆ Table of Contents

Publish

Step-by-step, ready-to-run command sequence for your first-time npm publish of minimal-engine-lib. This assumes you’re using pnpm and have an npm account.

Build the library

Run the Vite build script you already have:

pnpm run build
  • Output goes to dist/
  • Files: minimal-engine.esm.js, minimal-engine.cjs.js, minimal-engine.umd.js
  • Source maps: optional, already enabled

Test locally (optional but recommended)

pnpm pack
# This creates a tarball like minimal-engine-lib-0.1.0.tgz
# You can install it in another project to make sure it works:
pnpm add ./minimal-engine-lib-0.1.0.tgz

Login to npm

pnpm login

Enter your npm username, password, and email.

Publish to npm

Add .env file (add to .gitignore).
Put your token there, npm shows it only once after generating it.

NPM_TOKEN=your_token_here

Add file .npmrc.

registry=https://registry.npmjs.org/
//registry.npmjs.org/:_authToken=${NPM_TOKEN}
export NPM_TOKEN=<your-token>
pnpm publish --access public

Use --access public if your package name is scoped (e.g., @yourname/minimal-engine-lib).

Future updates

When you make changes:

pnpm version patch   # or minor/major
pnpm run build
pnpm publish --access public

Each publish must have a unique version.

⬆ Table of Contents