I Built a Game Engine With No Build Step
My game engine has thirteen TypeScript packages, a WebGL2 renderer, a physics solver, and an offline path tracer. It has no build step. No webpack, no esbuild, no dist/ of compiled JS. I edit a .ts file and run it. That's the whole workflow.
This isn't a trick — it's a feature Node shipped and most people haven't noticed yet.
Node runs TypeScript now
As of Node 22, the runtime strips type annotations at load time and executes what's left. node server.ts just works. There's no transpile pass, no source maps to wire up, no ts-node in the loop — the types are erased, not compiled.
So across the whole repo, tsc never emits a single file. It runs as a pure checker:
{ "compilerOptions": { "noEmit": true, "allowImportingTsExtensions": true } }
Typechecking and running are now two independent things. CI runs tsc --noEmit to catch type errors. Node runs the same .ts sources to actually do the work. Neither one produces build output, because there is no build output.
The real payoff: one codebase, two runtimes
The reason I care isn't the missing dist/ folder — it's what type-stripping unlocks. The engine's math and physics packages are plain TypeScript with zero dependencies. That means the exact same Vec3 and the exact same rigid-body World run in:
- the browser client (a WebGL2 physics sandbox), and
- the Node server (an offline Monte-Carlo path tracer).
Same files. Not "ported." Not "shared via a compiled npm package." The server imports @engine/physics and the browser imports @engine/physics and it's the same src/.
import { Vec3 } from "@engine/math";
import { World, RigidBody } from "@engine/physics";
const world = new World(new Vec3(0, -9.81, 0), { restitution: 0.5 });
world.add(new RigidBody({ mass: 1, position: new Vec3(0, 5, 0),
collider: { kind: "sphere", radius: 0.5 } }));
world.step(1 / 60);
Normally, sharing simulation code between a JS frontend and a native backend means one of two miserable options: rewrite it twice, or compile it to WASM and marshal data across the boundary. Type-stripping deletes that whole problem. TypeScript already targets both runtimes; once you stop treating "compile to JS" as a mandatory step, the frontend/backend split stops costing you anything.
What it costs
Erasing types instead of compiling them means a few TypeScript features are off the table — the ones that emit runtime code from type syntax:
- No
enum(use aconstobject + a union type). - No parameter properties (
constructor(private x: number)— write the field out). - No namespaces with runtime bodies, no
experimentalDecoratorsthat emit.
These are exactly the TypeScript features that were always a bit odd anyway — the ones where the type layer secretly generates JavaScript. Give them up and the language becomes "JavaScript with annotations you can delete," which is what it should have been all along. My lint rules just ban them, and I've never missed them.
The other cost: for a published npm package you still want to ship compiled .js so consumers on older runtimes can use it. Fine — add a build step at the publish boundary, for that one package, when you actually publish it. Not before, and not for the ninety-nine percent of the time you're just developing.
The lesson
For years "set up the build" was step zero of every TypeScript project — the bundler config, the watch mode, the source maps, the dist/ you gitignore. I deleted all of it and lost nothing except the config files. The engine runs the sources I wrote, the typechecker reads the same sources, and the browser and server share every line of math between them.
The build step was solving a problem the runtime now solves for free. Once that's true, the laziest option and the correct option are the same one: don't build.