AI & ML

Game Dev Without An Engine: The 2025/2026 Renaissance

· 5 min read
SitePoint Premium
Stay Relevant and Grow Your Career in Tech
  • Premium Results
  • Publish articles on SitePoint
  • Daily curated jobs
  • Learning Paths
  • Discounts to dev tools
Start Free Trial

7 Day Free Trial. Cancel Anytime.

In early 2025, Noel Berry published "Making Video Games in 2025," an essay that struck a nerve across game development communities. A growing number of indie developers and hobbyists are choosing to build games without Unity, Unreal, or Godot — and this article explains the philosophy behind that movement and then puts it into practice.

How to Build a Game Without an Engine

  1. Scaffold a minimal project with a single HTML file, a Canvas element, and a JavaScript entry point — no package manager or framework required.
  2. Implement a fixed-timestep game loop using requestAnimationFrame with an accumulator to decouple logic updates from render speed.
  3. Render sprites and shapes via the Canvas 2D API, using draw-order sorting and a camera offset for scrolling.
  4. Abstract input by collecting keyboard, mouse, and gamepad events into a state map queried each frame with isPressed, isHeld, and isReleased.
  5. Manage entities with a simple array of objects, each exposing its own update and render methods.
  6. Detect collisions using axis-aligned bounding box (AABB) checks between entity pairs.
  7. Compose all subsystems into a playable demo, verifying the loop, rendering, input, and entity interaction work together.
  8. Extend incrementally by adding audio, tilemaps, scene management, or a WebGL renderer only when the game demands it.

Table of Contents

Why Developers Are Ditching Game Engines

In early 2025, Noel Berry published "Making Video Games in 2025," an essay that struck a nerve across game development communities. Berry, the co-creator of Celeste, argued that developers had become over-reliant on monolithic engines, and that building games from scratch using lightweight libraries was not only viable but preferable for many projects. The essay went viral, crystallizing a sentiment that had been building for years: a growing number of indie developers and hobbyists are choosing to build games without Unity, Unreal, or Godot.

The reasons are practical, not ideological. Unity's runtime fee pricing controversy in 2023 shook developer trust in ways that persist into 2025 and 2026, and Unreal's royalty structure and enormous project sizes make it unwieldy for small teams. Even Godot, despite its open-source appeal, still imposes its own abstractions and workflow opinions. The counter-movement is straightforward: replace the engine with a small set of libraries and custom code, retaining full ownership and understanding of every system in the game.

This article explains the philosophy behind that movement and then puts it into practice. Readers will build a minimal but functional game framework in JavaScript using the Canvas 2D API, implementing a game loop, rendering, input handling, and entity management from scratch. The target audience is intermediate developers comfortable with JavaScript who want to understand what a game engine actually does under the hood, one subsystem at a time.

The 2025/2026 Engine-Free Renaissance: What Changed?

The Cultural Catalysts

Noel Berry's essay served as a tipping point, but the cultural shift had been gathering momentum. The team behind Celeste Classic built the original jam version in Game Maker Studio before evolving it into a hand-rolled C# framework for the full release. Balatro's developer built it with LÖVE, a 2D game framework that uses Lua as its scripting language, not a mainstream engine. These success stories proved that polished, commercially successful games do not require heavyweight engines.

The Unity pricing controversy accelerated this shift dramatically. When Unity announced (and later partially walked back) its runtime fee structure, it did more than anger developers. It exposed a fundamental vulnerability: building on a platform controlled by a corporation with shifting incentives. The aftershocks still reverberate in 2025, with many developers diversifying away from proprietary engines entirely.

Community sentiment has shifted toward what developers now call "engine fatigue." Developers report frustration with engine update cycles breaking projects, opaque rendering pipelines they cannot debug, and project sizes reaching hundreds of megabytes for simple 2D games. The desire is for ownership and understanding, not just productivity.

The desire is for ownership and understanding, not just productivity.

The Technical Enablers

The tooling available in 2025 and 2026 has matured to the point where going engine-free no longer means going primitive. Modern languages like Zig, Odin, and Rust offer ergonomics that make low-level game programming far less painful than C or C++ historically demanded. For browser-based development, JavaScript and TypeScript paired with Canvas, WebGL, or the emerging WebGPU API provide a runtime covering 2D rendering, audio, and gamepad input with zero installation friction.

Lightweight libraries have stepped in to replace individual engine subsystems without imposing framework-level constraints. SDL, Raylib, and sokol handle windowing and graphics at the native level. In the browser, the Canvas 2D API, the Web Audio API, and the Gamepad API cover the essentials. The philosophy is "library, not framework": pull in exactly what you need, compose systems yourself, and understand every layer.

Improved tooling seals the deal. Hot reload workflows, GPU debugging tools, and browser DevTools give developers sub-second iteration cycles. Developers working in the browser can inspect, profile, and debug their game in the same environment they use for web development.

Who Is This For (And Who Should Still Use an Engine)?

Honesty matters here. Engine-free development works best for solo or two-person teams building 2D games with fewer than roughly 500 entities, learning-focused developers who want to deeply understand game systems, game jam entries, and creative or experimental projects where engine conventions would constrain the design.

Developers working on large 3D team projects, shipping under tight deadlines with established pipelines, or relying on extensive tooling like Unreal's visual scripting or Unity's animation system should continue using those engines. The goal is not to replace engines for everyone. It is to make informed choices about when an engine's overhead is worth the trade-off.

Core Concepts: What a Game Engine Actually Does

The Five Pillars You Are Replacing

Every game engine, from Unity to a custom C framework, provides roughly the same core subsystems. Understanding these pillars is the key to building your own.

The game loop is the heartbeat of any game. It continuously cycles between updating game logic and rendering the result to screen. The critical design decision is fixed versus variable timestep. Fixed timestep updates greatly reduce non-determinism from variable frame times, keeping physics and game logic consistent regardless of frame rate (floating-point non-determinism across platforms is a separate concern not addressed here), while variable rendering allows smooth visuals on fast hardware.

Rendering covers drawing sprites, tilemaps, text, and effects to the screen. In engine-free development, this means directly calling graphics APIs rather than dragging assets into an editor. Input handling abstracts keyboards, mice, and gamepads into a unified interface that game logic queries without caring about the specific device.

Entity and scene management organizes game objects. This ranges from simple arrays of objects to full Entity Component System (ECS) architectures, depending on project complexity. Asset loading covers getting images, audio, fonts, and data files into memory and ready for use.

The Mental Model Shift

The shift from engine-based to engine-free development is a shift from "configure the engine" to "compose small systems." Developers do not need all five pillars implemented on day one. Building incrementally, starting with the game loop and adding systems as the game demands them, is the natural workflow.

Critically, understanding these systems deeply makes developers more effective even if they return to an engine later. Knowing what requestAnimationFrame does, how input state machines work, or why draw order matters transforms an engine user from a consumer of black boxes into someone who can debug, optimize, and extend any tool.

Knowing what requestAnimationFrame does, how input state machines work, or why draw order matters transforms an engine user from a consumer of black boxes into someone who can debug, optimize, and extend any tool.

Setting Up Your Engine-Free Project

Project Structure and Tooling

One of the most striking aspects of engine-free browser game development is how little setup it requires. No project wizard, no package manager dependency tree, no framework boilerplate. The entire project starts with a single HTML file and a JavaScript entry point. Because type="module" is blocked over file://, serve the project locally: npx serve . or python3 -m http.server 8000 — opening index.html directly via file:// will fail with a CORS error. Node.js 18 LTS or later is recommended; verify with node --version.

<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Engine-Free Game</title>
  <style>
    body { margin: 0; background: #111; display: flex; justify-content: center; align-items: center; height: 100vh; }
    canvas { border: 1px solid #333; }
  </style>
</head>
<body>
  <canvas id="game" width="640" height="480"></canvas>
  <script type="module" src="main.js"></script>
</body>
</html>
// main.js — Entry point
const canvas = document.getElementById("game");
const ctx = canvas.getContext("2d");

console.log("Engine-free game initialized.", canvas.width, canvas.height);

That is the entire scaffold. The folder structure can be as simple as index.html, main.js, and an assets/ directory. Compare this to the hundreds of megabytes a typical Unity or Unreal project generates (depending on engine version and project template) before a single line of game code is written.

Building the Game Loop

Why the Game Loop Is Everything

The game loop is the single most important system in any game. Without it, nothing moves, nothing responds, nothing renders. The loop continuously performs two operations: update game state, then render the result. This cycle repeats as fast as the system allows, ideally at 60 frames per second or higher.

The naive approach is to use requestAnimationFrame directly and pass the frame's delta time to the update function. This works but introduces a problem: game logic runs at variable speeds depending on frame rate, making physics and gameplay non-deterministic. A fixed timestep solves this by accumulating elapsed time and running update steps at a constant interval (for example, 1/60th of a second), regardless of how fast or slow frames render.

The following snippet is a standalone template. To integrate it with the rest of the framework, replace the update() and render() stubs with calls to your entity and rendering systems, and ensure canvas and ctx are in scope (for example, by declaring them at the top of the file or passing them in as parameters).

// gameloop.js — Fixed timestep game loop (template)
// For standalone use, uncomment the following two lines:
// const canvas = document.getElementById("game");
// const ctx = canvas.getContext("2d");

import { inputUpdate } from './input.js';

const TICK_RATE = 1 / 60;
let accumulator = 0;
let lastTime = null; // null signals "not yet initialized"
let rafHandle = null;

function update(dt) {
  // Game logic runs here at a fixed rate
}

function render() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  // Drawing happens here
}

function loop(currentTime) {
  if (lastTime === null) {
    lastTime = currentTime; // Initialize on first real call — no ticks on frame 0
    rafHandle = requestAnimationFrame(loop);
    return;
  }

  const frameTime = Math.min((currentTime - lastTime) / 1000, 0.25); // Cap to avoid spiral of death
  lastTime = currentTime;
  accumulator += frameTime;

  while (accumulator >= TICK_RATE) {
    update(TICK_RATE);
    accumulator -= TICK_RATE;
  }

  inputUpdate(); // Called once per frame, after all fixed-timestep ticks
  render();
  rafHandle = requestAnimationFrame(loop);
}

export function startLoop() {
  rafHandle = requestAnimationFrame(loop);
}

export function stopLoop() {
  if (rafHandle !== null) {
    cancelAnimationFrame(rafHandle);
    rafHandle = null;
  }
}

startLoop(); // Begin the loop

Testing Your Loop

Adding an FPS counter is a quick way to verify the loop is working correctly. Two common pitfalls to watch for: the "spiral of death," where slow updates cause the accumulator to grow unboundedly (handled above by capping frameTime at 0.25 seconds), and browser tab-backgrounding behavior, where browsers suspend requestAnimationFrame callbacks entirely when the tab is not visible, producing a large time spike when the user returns — exactly the case the 0.25-second cap handles.

Rendering Without a Renderer

Canvas 2D as Your GPU

The Canvas 2D API comfortably handles hundreds of draw calls per frame at 60 fps on modern hardware, and it serves as an ideal learning environment. It handles sprite drawing, transformations, text rendering, and basic compositing without requiring shader knowledge. For projects that outgrow Canvas 2D's throughput, WebGL provides hardware-accelerated 2D rendering, and WebGPU (now shipping in Chrome and Safari 17+, and available behind a flag in Firefox as of early 2025) represents the next generation.

The following renderer module requires canvas and ctx to be in scope or passed in. The drawSprite function checks whether the provided image has finished loading before attempting to draw; if the image is not yet ready, the call is silently skipped.

// renderer.js — Sprite rendering system
const spriteSheet = new Image();
spriteSheet.src = "assets/sprites.png";
spriteSheet.onerror = (e) => {
  console.error("Sprite sheet failed to load:", spriteSheet.src, e);
};

export function drawSprite(ctx, img, sx, sy, sw, sh, dx, dy, scale = 1, rotation = 0) {
  if (!img.complete || img.naturalWidth === 0) return; // Works for any Image object

  ctx.save();
  ctx.translate(dx + (sw * scale) / 2, dy + (sh * scale) / 2);
  ctx.rotate(rotation);
  ctx.drawImage(
    img,
    sx, sy, sw, sh,
    -(sw * scale) / 2, -(sh * scale) / 2, sw * scale, sh * scale
  );
  ctx.restore();
}

// Camera offset for scrolling
export const camera = { x: 0, y: 0 };

export function renderSprite(ctx, img, sx, sy, sw, sh, worldX, worldY, scale, rotation) {
  drawSprite(
    ctx, img, sx, sy, sw, sh,
    worldX - camera.x, worldY - camera.y,
    scale, rotation
  );
}

// Usage in render():
// renderSprite(ctx, spriteSheet, 0, 0, 16, 16, player.x, player.y, 2, 0);

Layering and Draw Order

Draw order in 2D games follows the painter's algorithm: objects drawn later appear on top of objects drawn earlier. The simplest approach is to sort entities by a z-index or y-position value before rendering. Entities with lower values are drawn first (background), and entities with higher values are drawn last (foreground). For most 2D games with dozens to low hundreds of entities, sorting an array once per frame is computationally negligible. (JavaScript's Array.sort is stable in all modern engines.)

Input Handling: Keyboards, Mice, and Gamepads

Building an Input Abstraction Layer

Raw DOM event listeners fire asynchronously, which makes them awkward to use directly in a synchronous game loop. An input manager collects events into a state map that the game loop queries each frame, providing clean isPressed, isHeld, and isReleased semantics.

// input.js — Input manager
const keys = {};
const prevKeys = {};

window.addEventListener("keydown", (e) => { keys[e.code] = true; });
window.addEventListener("keyup", (e) => { keys[e.code] = false; });

function inputUpdate() {
  // Copy current state to previous state each frame
  const codes = Object.keys(keys);
  for (let i = 0; i < codes.length; i++) {
    prevKeys[codes[i]] = keys[codes[i]];
  }
}

function isHeld(code) {
  return !!keys[code];
}

function isPressed(code) {
  return !!keys[code] && !prevKeys[code];
}

function isReleased(code) {
  return !keys[code] && !!prevKeys[code];
}

export { inputUpdate, isHeld, isPressed, isReleased };

// Call inputUpdate() once per frame, *after* all fixed-timestep ticks have completed —
// place it after the while loop in loop(), not inside update().

Note: inputUpdate() must copy state after game logic has consumed it for the current frame, preserving the distinction between "just pressed this frame" and "being held down." If inputUpdate() is called inside the fixed-timestep while loop, isPressed will only return true on the first tick of a multi-tick frame and incorrectly return false on subsequent ticks.

Why Abstraction Matters Early

Decoupling game logic from specific input devices pays off immediately. Game code that checks isHeld("ArrowRight") or isPressed("Space") does not need to change when gamepad support is added later via the browser's Gamepad API. The abstraction layer absorbs device differences while the game logic remains clean.

Entity Management: A Minimal Approach

You Don't Need a Full ECS (Yet)

Entity Component System architectures are powerful for large games with thousands of entities and complex interactions. For solo or two-person teams building games with dozens to hundreds of entities, they are over-engineering. The spectrum runs from plain objects with update and render methods, to loosely structured component bags, to full ECS with archetypes and system schedulers. Starting simple is not a compromise; it is the correct choice at this scale.

// entities.js — Simple entity system
import { isHeld } from './input.js';

const entities = [];

function addEntity(entity) {
  entities.push(entity);
}

function removeEntity(entity) {
  const idx = entities.indexOf(entity);
  if (idx !== -1) entities.splice(idx, 1);
}

function updateEntities(dt) {
  for (const e of [...entities]) {
    if (e.update) e.update(dt);
  }
}

function renderEntities(ctx) {
  // No spread: render must not mutate entities array
  for (let i = 0, len = entities.length; i < len; i++) {
    if (entities[i].render) entities[i].render(ctx);
  }
}

// Sample player entity
const player = {
  x: 100, y: 100, size: 20, speed: 200,

  update(dt) {
    if (isHeld("ArrowRight")) this.x += this.speed * dt;
    if (isHeld("ArrowLeft")) this.x -= this.speed * dt;
    if (isHeld("ArrowDown")) this.y += this.speed * dt;
    if (isHeld("ArrowUp")) this.y -= this.speed * dt;
  },

  render(ctx) {
    ctx.fillStyle = "#0f0";
    ctx.fillRect(this.x, this.y, this.size, this.size);
  },
};

addEntity(player);

export { entities, addEntity, removeEntity, updateEntities, renderEntities, player };

Scene Management Basics

Model game states like menus, gameplay, and pause screens as scene objects, each with their own update and render methods. A simple state machine that tracks the current scene and delegates the loop's calls to it provides clean separation. Robert Nystrom's "Game Programming Patterns" (available free online at gameprogrammingpatterns.com) covers the state pattern in depth and is the recommended next resource for this topic.

Putting It All Together: A Playable Demo

Combine all prior systems to produce a complete, playable game in under 60 lines of JavaScript. The following example creates a player square that moves around the canvas collecting randomly placed items, with AABB collision detection and a score display.

// main.js — Complete mini-game
const canvas = document.getElementById("game");
const ctx = canvas.getContext("2d");
const TICK_RATE = 1 / 60;
let accumulator = 0, lastTime = null, score = 0;
let rafHandle = null;

// Input
const keys = {}, prevKeys = {};
window.addEventListener("keydown", (e) => { keys[e.code] = true; });
window.addEventListener("keyup", (e) => { keys[e.code] = false; });
function isHeld(code) { return !!keys[code]; }
function inputUpdate() {
  const codes = Object.keys(keys);
  for (let i = 0; i < codes.length; i++) prevKeys[codes[i]] = keys[codes[i]];
}

// Entities
const entities = [];
const spawnQueue = [];
function addEntity(e) { entities.push(e); }
function removeEntity(e) { const i = entities.indexOf(e); if (i !== -1) entities.splice(i, 1); }

// AABB collision — supports width/height or size fallback
function collides(a, b) {
  const aw = a.w ?? a.size;
  const ah = a.h ?? a.size;
  const bw = b.w ?? b.size;
  const bh = b.h ?? b.size;
  return a.x < b.x + bw && a.x + aw > b.x &&
         a.y < b.y + bh && a.y + ah > b.y;
}

// Player
const player = {
  x: 300, y: 220, size: 20, speed: 200,

  update(dt) {
    if (isHeld("ArrowRight")) this.x += this.speed * dt;
    if (isHeld("ArrowLeft")) this.x -= this.speed * dt;
    if (isHeld("ArrowDown")) this.y += this.speed * dt;
    if (isHeld("ArrowUp")) this.y -= this.speed * dt;
    this.x = Math.max(0, Math.min(canvas.width - this.size, this.x));
    this.y = Math.max(0, Math.min(canvas.height - this.size, this.y));
  },

  render(ctx) {
    ctx.fillStyle = "#0f0";
    ctx.fillRect(this.x, this.y, this.size, this.size);
  },
};
addEntity(player);

// Spawn collectibles
function spawnItem() {
  const ITEM_SIZE = 12;
  const item = {
    x: Math.random() * (canvas.width - ITEM_SIZE),
    y: Math.random() * (canvas.height - ITEM_SIZE),
    size: ITEM_SIZE,

    update() {
      if (collides(player, this)) {
        score++;
        removeEntity(this);
        spawnQueue.push(spawnItem); // Defer — do not mutate entities during iteration
      }
    },

    render(ctx) {
      ctx.fillStyle = "#ff0";
      ctx.fillRect(this.x, this.y, this.size, this.size);
    },
  };
  addEntity(item);
}
for (let i = 0; i < 5; i++) spawnItem();

// Game loop
function update(dt) {
  for (const e of [...entities]) if (e.update) e.update(dt);
  // Flush deferred spawns after iteration completes
  while (spawnQueue.length > 0) spawnQueue.pop()();
}

function render() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  for (let i = 0, len = entities.length; i < len; i++) if (entities[i].render) entities[i].render(ctx);
  ctx.fillStyle = "#fff";
  ctx.font = "18px monospace";
  ctx.fillText(`Score: ${score}`, 10, 26);
}

function loop(t) {
  if (lastTime === null) {
    lastTime = t; // Initialize on first real call — no ticks on frame 0
    rafHandle = requestAnimationFrame(loop);
    return;
  }

  const frameTime = Math.min((t - lastTime) / 1000, 0.25);
  lastTime = t;
  accumulator += frameTime;

  while (accumulator >= TICK_RATE) {
    update(TICK_RATE);
    accumulator -= TICK_RATE;
  }

  inputUpdate(); // Called once per frame, after all ticks
  render();
  rafHandle = requestAnimationFrame(loop);
}

rafHandle = requestAnimationFrame(loop);

What You Have Built vs. What an Engine Gives You

This entire framework sits at roughly 150 lines of JavaScript across all modules. A fresh Unity 2D project generates hundreds of megabytes of files (depending on Unity version and project template) before a single sprite is placed. The trade-off is explicit: what you built here lacks audio, physics simulation, particle systems, a UI toolkit, and an editor. But you understand every line, you can debug every system, and you make every extension a deliberate choice rather than an engine configuration hunt.

But you understand every line, you can debug every system, and you make every extension a deliberate choice rather than an engine configuration hunt.

The Engine-Free Implementation Checklist

SubsystemStatusComplexityPriority
Game Loop (fixed timestep)✅ BuiltLowCritical
Canvas 2D Rendering✅ BuiltLowCritical
Input Manager✅ BuiltLowCritical
Entity System✅ BuiltLowHigh
Collision Detection (AABB)✅ BuiltMediumHigh
Asset Loader / Preloader⬜ Next stepMediumHigh
Audio (Web Audio API)⬜ Next stepMediumMedium
Tilemap Renderer⬜ Next stepMediumMedium
Particle System⬜ LaterMediumLow
Scene/State Manager⬜ LaterLowMedium
Physics Engine⬜ LaterHighLow
UI System⬜ LaterHighLow
WebGL/WebGPU Renderer⬜ AdvancedHighLow
Level Editor (React-based)⬜ AdvancedHighLow

This checklist serves as a six-month roadmap. The "Level Editor" row is where a tool like React naturally enters the picture: not as part of the game runtime, but as one option for building custom editors and asset pipelines that feed data into the engine-free game (covered in a future article). Bookmark it and check items off as each system is implemented.

Resources and the Community Behind the Movement

Essential Reading and Watching

Noel Berry's "Making Video Games in 2025" is the starting point for the philosophy. The Handmade Hero project by Casey Muratori (handmadehero.org) — a C-based series — and the broader Handmade Network community provide deep-dive implementation examples. Robert Nystrom's "Game Programming Patterns" (available free online at gameprogrammingpatterns.com) covers the game loop, state, component, and observer patterns with clarity that directly maps to engine-free development. For developers interested in non-JavaScript paths, Raylib, SDL, and sokol are the libraries most commonly cited in engine-free communities.

Where to Go From Here

The most effective next step is to rebuild a classic game (Breakout, Asteroids, or a simple platformer) using only the systems built in this article. Once Canvas 2D performance becomes a bottleneck, porting the rendering layer to WebGL or WebGPU is a natural progression. The #engineless and #handmade communities on Discord, Twitter/X, and Mastodon provide feedback, inspiration, and solidarity for developers on this path.

Own Your Stack, Own Your Craft

The 2025/2026 engine-free renaissance is not anti-engine. It is pro-knowledge. Developers who understand what a game loop does, how input state machines work, and why draw order matters are better equipped to use any tool, whether that tool is Unity, Godot, or 150 lines of JavaScript they wrote themselves.

This article has demonstrated that a functional game framework, with a fixed-timestep loop, rendering, input handling, entity management, and collision detection, fits in code you can read in a single sitting. Now go build something with it.