minimal-engine-lib
v0.1.3
Published
`minimal-engine-lib` is a project with engine library version.
Readme
Minimal Engine Library
minimal-engine-lib is a project with engine library version.
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.
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.
🔥 Architecture
/src
core.ts
loop.ts
renderer.ts
input.ts
audio.ts
index.ts
package.json
tsconfig.json
vite.config.ts🚀 Stack
(the simplest and modern)
- TypeScript
- Vite (dev server + build)
- ES modules
- Zero frameworks
📦 Installation
pnpm create vite@latest minimal-engine-lib -- --template vanilla-ts
cd minimal-engine-lib
pnpm install
pnpm run devThere is a need to remove template files not needed for library.
⚙️ Configuration
(Generated from vite)
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.json
{
"name": "minimal-engine-lib",
"version": "0.1.1",
"main": "dist/index.cjs.js",
"module": "dist/index.esm.js",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"exports": {
".": {
"import": "./dist/minimal-engine.es.js",
"require": "./dist/minimal-engine.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.ts
import { defineConfig } from "vite";
export default defineConfig({
build: {
lib: {
entry: "./src/index.ts", // your engine entry file
name: "MinimalEngine", // UMD global variable name
fileName: (format) => `minimal-engine.${format}.js`,
formats: ["es", "cjs", "umd"] // bundle in ES, CommonJS, and UMD
},
minify: true, // <- keep code readable
sourcemap: true, // optional, generates source maps for debugging
rollupOptions: {
external: [], // any external dependencies you don't want to bundle
}
}
});Implementation
Game Loop
Classic fixed update + render.
✔ constant interval physics
✔ smooth render
loop.ts
export class GameLoop {
private lastTime = 0;
private accumulator = 0;
private readonly fixedDelta = 1 / 60;
constructor(
private update: (dt: number) => void,
private render: (alpha: number) => void
) {}
start() {
requestAnimationFrame(this.tick);
}
private tick = (time: number) => {
if (!this.lastTime) {
this.lastTime = time;
}
const delta = Math.min((time - this.lastTime) / 1000, 0.25);
this.lastTime = time;
this.accumulator += delta;
while (this.accumulator >= this.fixedDelta) {
this.update(this.fixedDelta);
this.accumulator -= this.fixedDelta;
}
const alpha = this.accumulator / this.fixedDelta;
this.render(alpha);
requestAnimationFrame(this.tick);
};
}Renderer
Simple fullscreen canvas renderer with resize handling.
✔ auto-resize
✔ context management
✔ clear utility
renderer.ts
export class Renderer {
public ctx: CanvasRenderingContext2D;
public canvas: HTMLCanvasElement;
constructor() {
const canvas = document.getElementById("canvas") as HTMLCanvasElement;;
if (!canvas) throw new Error("Canvas not found on page!")
this.canvas = canvas
this.ctx = this.canvas.getContext("2d")!;
window.addEventListener("resize", this.resize);
this.resize();
}
resize = () => {
const dpr = window.devicePixelRatio || 1;
this.canvas.width = window.innerWidth * dpr;
this.canvas.height = window.innerHeight * dpr;
this.canvas.style.width = window.innerWidth + "px";
this.canvas.style.height = window.innerHeight + "px";
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
};
clear() {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
}
}Input
Simple keyboard input handling with Set.
✔ key tracking
✔ clean API
✔ lightweight
input.ts
export class Input {
private keys = new Set<string>();
constructor() {
window.addEventListener("keydown", (e) => {
if (["ArrowUp","ArrowDown","ArrowLeft","ArrowRight"].includes(e.key)) {
e.preventDefault();
}
this.keys.add(e.key);
});
window.addEventListener("keyup", (e) => this.keys.delete(e.key));
}
isDown(key: string) {
return this.keys.has(key);
}
}Audio
- Minimal Web Audio manager with looping music support and volume control
- SFX support –
load()+play()for short sounds. - Looping music –
playMusic()with optional volume. - Volume control –
setMusicVolume(). - Safe browser autoplay handling – resumes
AudioContexton first user interaction. - Single music track at a time – clean and minimal.
- Fully modular, fits your engine architecture.
- SFX support –
audio.ts
export class AudioManager {
private context: AudioContext;
private buffers = new Map<string, AudioBuffer>();
private unlocked = false;
private musicSource: AudioBufferSourceNode | null = null;
private musicGain: GainNode;
constructor() {
this.context = new AudioContext();
this.musicGain = this.context.createGain();
this.musicGain.connect(this.context.destination);
const unlock = async () => {
if (this.context.state === "suspended") {
await this.context.resume();
}
this.unlocked = true;
window.removeEventListener("click", unlock);
window.removeEventListener("keydown", unlock);
};
window.addEventListener("click", unlock, { once: true });
window.addEventListener("keydown", unlock, { once: true });
}
async load(name: string, url: string) {
const res = await fetch(url);
const arrayBuffer = await res.arrayBuffer();
const audioBuffer = await this.context.decodeAudioData(arrayBuffer);
this.buffers.set(name, audioBuffer);
}
play(name: string) {
if (!this.unlocked) return;
const buffer = this.buffers.get(name);
if (!buffer) return;
const source = this.context.createBufferSource();
source.buffer = buffer;
source.connect(this.context.destination);
source.start();
}
playMusic(name: string, volume = 1) {
if (!this.unlocked) return;
const buffer = this.buffers.get(name);
if (!buffer) return;
this.stopMusic();
this.musicGain.gain.value = volume;
const source = this.context.createBufferSource();
source.buffer = buffer;
source.loop = true;
source.connect(this.musicGain);
source.start();
this.musicSource = source;
}
stopMusic() {
if (this.musicSource) {
this.musicSource.stop();
this.musicSource.disconnect();
this.musicSource = null;
}
}
setMusicVolume(volume: number) {
this.musicGain.gain.value = volume;
}
async playMusicAfterGesture(name: string, volume = 1) {
if (this.context.state === "suspended") {
await this.context.resume();
}
this.unlocked = true;
this.playMusic(name, volume);
}
}Index
index.ts
export * from "./core";
export * from "./loop";
export * from "./renderer";
export * from "./input";
export * from "./audio";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.tgzLogin to npm
pnpm loginEnter 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_hereAdd file .npmrc.
registry=https://registry.npmjs.org/
//registry.npmjs.org/:_authToken=${NPM_TOKEN}export NPM_TOKEN=<your-token>
pnpm publish --access publicUse
--access publicif 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 publicEach publish must have a unique version.
