nacatamalon
v0.7.0
Published
A retro-first 2D and 3D game engine for the web, on WebGPU with a WebGL2 fallback, written with hooks and plain functions.
Maintainers
Readme
nacatamalon
Make games that look like 1997.
A 2D and 3D game engine for the web, written in TypeScript, on WebGPU with a WebGL2 fallback.
A letter, before the code
There was a time when a game fit in your hands. A cartridge, a disc, a handful of colours, a screen that drew a few hundred thousand pixels and made you believe in whole worlds. The people who made those games did not have more. They had less, and they knew exactly what to do with it.
nacatamalon is written for that kind of game, and for the people who still want to make one.
It aims at the consoles from the SNES to the GameCube, and that is a choice, not a lack. A short palette, dithering, pixels that land exactly where you put them, light worked out per vertex, a blob of shadow under a character: these are not things the engine settles for. They are the things it is best at, kept cheap and close at hand, because nobody else on the web ships them and because they are beautiful.
The limitation is the style. An engine that can do everything asks you to decide everything. This one has an edge, the era it loves, and inside that edge it tries to make every step short. It will not grow physically based rendering, screen-space reflections or global illumination. If a game from the Dreamcast years would not have needed something to ship, it is not here. If it would have, a score counter, a gamepad, a tilemap, a phone to run on, it is, because a game that looks like a SNES still needs all of that.
Games that get finished, not projects that get abandoned. That is the whole point. A scene is a function. What it needs, it asks for with hooks. What it makes is plain data you can change. You should be able to read a game written with it from top to bottom, and to understand the engine underneath it too: it is small enough on purpose for one person to hold in their head.
It belongs on the web. No install for the player, no store between you and them. A game is a page, and publishing one is copying a folder.
If you grew up blowing into cartridges, or you never did and you simply like how those games look, welcome. Make something small, and finish it.
Francisco José Pereira Alvarado
Install
Start a new game from the template (Vite, one scene, TypeScript or JavaScript):
npm create nacatamalon@latestOr add it to a project you already have:
npm install nacatamalonIt is an ES module with types included, meant to be used with a bundler such as Vite. It runs on WebGPU (Chrome and Edge 113+, Safari 26) and falls back to WebGL2 everywhere else.
nacatamalon is young (0.x). The API is settling and can still change between versions.
A game
import {
createGame,
createScene,
createSprite,
useKeyboard,
useUpdate,
} from "nacatamalon";
// GameScene component
const Game = () => {
const keys = useKeyboard();
const paddle = createSprite({
transform: { x: 240, y: 296 },
width: 64,
height: 10,
tint: "#ff6b1a",
});
useUpdate((dt) => {
if (keys.isDown("ArrowLeft")) {
paddle.transform.x -= 340 * dt;
}
if (keys.isDown("ArrowRight")) {
paddle.transform.x += 340 * dt;
}
});
return createScene();
};
// Main file
const game = createGame("#app", {
width: 480,
height: 320,
background: "#12102b",
});
// Set scenes
game({ Game });With an element to draw into on the page:
<div id="app"></div>createGame(target, options)makes the window; calling what it returns with your scenes starts it.- A scene is a function that runs once, when it starts. Inside it,
create*puts things in it (createSprite,createText,createMesh...) anduse*asks for what it needs: a key, a texture, code that runs every frame. useUpdate(fn)runsfnevery frame with the seconds since the last one, and the seconds the scene has been running.splash: truein the options opens the published game with a short "Made with NacatamalOn" (under two seconds, a key or a click skips it) while the first scene loads behind it. It is off unless you ask for it, and skipped while you develop onlocalhost.
Coding with an AI assistant
The package carries two files written for coding assistants, so they work offline and always match the version you installed:
node_modules/nacatamalon/llms.txt: the short guide. How a game is written (a scene runs once, hooks only inside it,create*returns data,useUpdateruns every frame), the conventions and the usual mistakes.node_modules/nacatamalon/llms-full.txt: every export with its signature and documentation, in one file to search.
An assistant can start a project in one go, without questions; the project it makes carries an
AGENTS.md that points at both files:
npm create nacatamalon@latest my-game -- --template vite-ts --installAssistants usually find them through the note at the top of the package's types. To be sure, add a
line to your project's instructions file (AGENTS.md, CLAUDE.md, .cursorrules):
This project uses the nacatamalon game engine: read node_modules/nacatamalon/llms.txt before writing game code.To check what a game does, an assistant (or a test of yours) can run it with no browser, from Node or Bun, a frame at a time and the same way every time:
import { createHeadlessGame } from 'nacatamalon/headless';
import { Level } from './src/scenes/level';
const game = createHeadlessGame({ width: 320, height: 180, scenes: { Level } });
await game.loaded();
game.press('ArrowRight');
game.run(60);
console.log(game.drawn().map((p) => p.text ?? p.name));How the API reads
- What
create*gives back is data: change its fields (sprite.transform.x += 1,sprite.tint = red) and the next frame shows it. - What acts on it is a function that takes it first:
destroy(sprite),setTile(map, …),playParticles(emitter). - What a hook gives back is a handle with methods:
keys.isDown('ArrowLeft'),useScene().change('Level').
What it has
- The era, first-class: palette matching, ordered dithering, colour grading tables and a CRT pass as post-processing; crisp pixel art (nearest sampling and whole-number scaling); vertex lighting, Nintendo 64 fog, PlayStation-style vertex snapping and affine textures, and blob shadows.
- 2D: sprites and atlases, sprite animation, tilemaps with solid-tile queries, bitmap text, nine-slice, particles, and coloured lights with ambient darkness and radial falloff (2D lighting guide).
- 3D: meshes, glTF models with skeletal animation, up to eight lights, shadow maps, 3D particles, and 2D and 3D in the same frame.
- Everything around it: scenes with transitions and pause, keyboard,
pointer, gamepad and named actions, audio placed in the world, stores and
signals, tweens and timers, seeded random, and the everyday maths (
clamp,lerp, vectors).
createText({ text: 'SCORE 0' }) works as is, with the font the engine carries
(Nacatamal Arcade, an 8 px arcade font with lowercase, accents and ñ). Two more
come with it, named by key: font: 'nacatamalon:square' (square corners) and
font: 'nacatamalon:thin' (one-pixel strokes). Load a
.ttf or .woff with useLoadFont, sharp at any size, or a bitmap font with
useLoadBitmapFont, and hand it to createText as font. A text breaks its own
lines at style.maxWidth, and measureText says how big a text is and where its
lines break, from the text itself or, before there is one, from its words and style.
Music is written as notes and played by the engine's own synthesizer, as the
NES, the SNES or the Genesis sounded, with instruments, echo and pan, and
thirty-nine public domain songs ready to play by key. A song changes tempo, key and
voices while it plays. The guide, from the first note to a full arrangement,
ships with the package: node_modules/nacatamalon/guides/music.md, and in
Spanish, guides/music.es.md.
Physics
Both physics adapters ship inside nacatamalon, and so do the engines they
drive: Rapier2D for nacatamalon/physics2d and box3d for nacatamalon/physics3d.
Each sits behind its own import, so a game that does not simulate never bundles
one. Call the install function of the adapter you use once:
nacatamalon/physics2d: Rapier2D's types useSymbol.dispose, so withskipLibCheckoff, add"ESNext.Disposable"tolib.
import { installPhysics2d } from "nacatamalon/physics2d";
installPhysics2d();Plugins
A plugin is a package other games import: written as a game is, with the same hooks, and used by
calling what it exports. Start one from the plugin template,
npm create nacatamalon@latest my-plugin -- --template plugin. How it is made, the rules it keeps,
how it is shared and the store: node_modules/nacatamalon/guides/plugins.md, and in Spanish,
guides/plugins.es.md.
Community
- Website: nacatamalon.com
- Discord: discord.gg/jtyY3RkYTc
- X (Twitter): @nacatamalcore
License
MIT © 2026 Francisco José Pereira Alvarado. See LICENSE. The built-in fonts are part of the engine and under the same licence: a game that uses them owes no credit of its own.
