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.
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
oop/
script/setup.ps1
package.json
tsconfig.json
vite.config.ts
.gitignore
.env
.npmrcFiles 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."🚀 Stack
(the simplest and modern)
- TypeScript
- Vite (dev server + build)
- ES modules
- Zero frameworks
Project
📦 Installation
pnpm create vite@latest light-engine-procedural -- --template vanilla-ts
cd light-engine-procedural
pnpm install
pnpm run devThere is a need to remove template files not needed for library.
This project was copied from light-engine-oop so create was not used.
⚙️ 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?
.envEngine
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;
}
}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;
}
}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);
}
}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 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;
}
}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;
}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";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.
