Handmade Web Games

Chapter 1

Tooling for web games in 2026

A minimal and reusable TypeScript + Vite setup for browser games

It may seem strange for a handmade game development guide to begin with the installation of a bunch of dependencies. Not to worry—the code in your games will remain dependency-free if you choose to keep it so. This tooling is mainly here to improve the development experience.

In this guide we'll lean on today's industry-standards for web projects:

  • TypeScript — an improved superset of JavaScript
  • Vite — a bundler (more on that below)
  • Node.js — a runtime and installer for Vite

We'll configure a simple, reusable starting point from which you can build just about any browser game. These examples use Node.js with npm, so make sure to have it installed before you proceed. (Alternatives like Bun and Deno also work.)

The bundler

Vite is a bundler. It takes the code from your many source files and assembles them into a single "bundle", hence the name. In 2026, bundlers also do a whole lot more. Vite will also...

  • Transpile TypeScript source code into JavaScript that the browser can run.
  • Run a local dev server with live reloading and hot module replacement.
  • Generate source maps for debugging.
  • Optimize assets for production builds.
  • Allow you to import assets and optionally inline them directly into your bundled code. Images, sound effects, wasm files, and fonts are much easier to work with when using Vite.

Scaffold the project

Scaffold out a minimal app by running the following command:

npm create vite@latest

After naming your project in Vite's CLI, pick the "vanilla" and "TypeScript" options.

$ npm create vite@latest

◇  Project name:
│  asteroids

◇  Select a framework:
│  Vanilla

◇  Select a variant:
│  TypeScript

◇  Install with npm and start now?
│  No

Enter the project directory and install its dependencies:

cd asteroids
npm install

Your project should look roughly like:

index.html
package.json
package-lock.json
counter.ts
main.ts
style.css
tsconfig.json

Go ahead and delete everything in the src/ folder, then add a new placeholder main.ts for us to return to later. You can put whatever you want in there for now.

src/main.ts
console.log("it works!");

Let's see if it actually works.

Start the dev server

First, start your local dev server so you can see changes live as you make them:

npm run dev

Then open the URL displayed in your terminal, which will probably be like http://localhost:5173. You'll just see a blank page for now. If you open the browser console, you should see the log you added in main.ts.

Next, we'll add a <canvas> element to the page.

Update main.ts

Create and mount a canvas

Starting from your placeholder, create a new canvas element and attach it to document.body.

src/main.ts
console.log("it works!");

const canvas = document.createElement("canvas");
document.body.appendChild(canvas);

Measure the canvas

Read the width and height of the canvas so we can fill it. getBoundingClientRect returns an object with position-related properties.

src/main.ts
console.log("it works!");

const canvas = document.createElement("canvas");
document.body.appendChild(canvas);
const bounds = canvas.getBoundingClientRect();

Paint the background

Fill the canvas with a blue rectangle. We'll cover this more in Chapter 2.

src/main.ts
console.log("it works!");

const canvas = document.createElement("canvas");
document.body.appendChild(canvas);
const bounds = canvas.getBoundingClientRect();

const ctx = canvas.getContext("2d")!;
ctx.fillStyle = "blue";
ctx.fillRect(0, 0, bounds.width, bounds.height);

You should now see a blue rectangle in the top-left corner, which is the canvas at its default size (300px wide by 150px). Next, we'll expand it to fill the entire window.

Update index.html

Base

Start from Vite's generated HTML.

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Your Game</title>
  </head>
  <body>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

Add a canvas style block

Add an inline <style>. This stretches the canvas to fill available space and ensures there won't be scrollbars.

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Your Game</title>
    <style>
      canvas {
        position: fixed;
        inset: 0;
        height: 100%;
        width: 100%;
      }
    </style>
  </head>
  <body>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

If you see a full-page blue rectangle, you have successfully set up all the tooling you'll need to follow this guide.

Optional configuration tweaks

Vite handles a lot out of the box, but one thing it does not do is automatically compress image assets. For that, we'll add a plugin.

Update vite.config.ts

Installation:

npm install vite-plugin-image-optimizer sharp svgo

Next, create or edit vite.config.ts in your project root:

Default config

Starting with a minimal config...

vite.config.ts
import { defineConfig } from "vite";

export default defineConfig({});

Image optimizer

Add the image optimizer plugin you just installed. Import ViteImageOptimizer and insert it into the plugins array.

vite.config.ts
import { defineConfig } from "vite";
import { ViteImageOptimizer } from "vite-plugin-image-optimizer";

export default defineConfig({
  plugins: [ViteImageOptimizer()],
});

Source maps

As we're already making changes to the Vite config, this is a good moment to enable source maps [MDN] with sourcemap: true. This way, even after Vite compresses and minifies your final build, you and others will be able to access the unminified TypeScript from the browser's devtools.

Why? (A philosophical detour on source maps)

One of the defining characteristics of the web is that you can read the full source code of any webpage you load by simply clicking View Source or Inspect Element in your browser.

However, modern websites and games use source code minification to reduce bundle sizes, which makes it difficult to learn anything interesting from the source directly.

See for yourself. The following minified code...

example.min.js
loading...

...was the compressed output of this source code:

example.js
// draw a health bar above the character
function drawHealthBar(ctx, x, y, health) {
  const maxHealth = 100;
  const barWidth = 48;
  const barHeight = 6;
  const healthPercent = health / maxHealth;
  const width = barWidth * healthPercent;

  // dark background
  ctx.fillStyle = "#1e1e2e";
  ctx.fillRect(x, y - 12, barWidth, barHeight);

  // green when healthy, red when critical
  const isCritical = healthPercent < 0.3;
  ctx.fillStyle = isCritical ? "red" : "#1e754f";
  ctx.fillRect(x, y - 12, width, barHeight);
}

const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");

let currentHealth = 50;
let x = 0;
let y = 16;

drawHealthBar(ctx, x, y, currentHealth);

A source map is simply an additional file that is output by the bundler that makes the original source code readable.

With source maps, you get the best of both worlds: players can load your game quickly thanks to the minified bundle, and curious game developers can read and learn from your source in their dev tools.

Minification and obfuscation won't stop people from reverse engineering your games, but they will stop curious minds from learning from you. So embrace the open ethos of the web and enable source maps for your games!

To make it personal for a moment: my own interest in building web games started when I clicked View Source on A Dark Room way back in 2014 and realized that building a game like this was maybe within reach. That game had readable, unminified, familiar-looking source code—and very little of it! You could be responsible for sparking a similar moment for the visitor that clicks View Source on your game someday.

vite.config.ts
import { defineConfig } from "vite";
import { ViteImageOptimizer } from "vite-plugin-image-optimizer";

export default defineConfig({
  build: {
    sourcemap: true,
  },
  plugins: [ViteImageOptimizer()],
});

Finally, make sure everything builds successfully for production:

npm run build

On this page