NacatamalOn HTML5 SameGame powered by samegame-engine, a headless TypeScript class
Some time ago, I built samegame-engine, a headless TypeScript class that handles a SameGame board without knowing anything about rendering, and I wrapped it in Phaser 4.
Now I took the very same engine, without changing a single line, and gave it a completely different renderer: NacatamalOn, a young web engine built for the look of the consoles from the SNES to the GameCube.
Have a look at the game:
The rules are the classic ones. Click a group of two or more adjacent tiles of the same color to remove them, gravity pulls the remaining tiles down, and empty columns collapse to the left.
Removing n tiles scores n * (n – 1), multiplied by the current level, so bigger groups are worth much more. Clear the board and you move on to the next level; run out of moves and the game restarts.
Two keys are new in this version:
- C switches the CRT effect off and on
- D switches the dithering off and on
Turn them off, look at the plain picture underneath, then turn them back on: that is the difference between a modern renderer and one built for the look of 1997.
Why a headless engine pays off
This port is a good demonstration you should always build headless engines. SameGame.ts and FloodFill2D.ts were copied into the new project as they are, without changing even a single line.
The engine is generic over the tile type, so it stores whatever object you hand it. It only asks for two things:
- a generator, a function that creates a new tile
- a comparator, a function that says whether two tiles belong to the same region
In both versions a tile is the same tiny interface: a type, the number the engine compares, and a sprite, which only the renderer touches. The only thing that changed is the type of that sprite.
When the player clicks, removeRegion(row, col) returns a description of what happened: which tiles were removed, which ones moved because of gravity, which ones shifted when a column emptied out.
The scene turns that list into animations, in the same four steps in both versions: removeBlocks fades the region out, makeBlocksFall drops the tiles above, compactTable slides the columns, newMove decides whether the player can go on, clears a level or starts over.
What is NacatamalOn
NacatamalOn is a 2D and 3D engine for the web, written in TypeScript by Francisco Pereira Alvarado. It draws with WebGPU where the browser supports it and falls back to WebGL2 everywhere else.
What makes it different is its scope, which is fenced in on purpose. It aims at the consoles from the SNES to the GameCube, so palette reduction, dithering, vertex snapping, N64 fog and CRT effects are built in, while physically based rendering and the like will never be.
It is young, version 0.1.9 at the time of writing, and the API can still change between versions. The source is on GitHub under the MIT license, and the docs come with a beginner tutorial that builds a Breakout in nine chapters.
From Phaser 4 to NacatamalOn
The structure of the scene is the same as in the Phaser 4 version: constants at the top, best score read from localStorage, texts, a sprite pool, the generator and the comparator, the board, the input, then the four steps of a move. Only the calls changed:
| What | Phaser 4 | NacatamalOn |
|---|---|---|
| The scene | a Scene class with preload() and create() | a function that runs once and returns createScene() |
| Tile art | this.load.spritesheet('tiles', ...) | createPixelTexture(Utils.paintTile(type, tileSize)), one texture per type |
| A sprite | this.add.sprite(x, y, 'tiles') | createSprite({ width, height, texture }) |
| Hide a pooled sprite | setActive(false).setVisible(false) | sprite.visible = false |
| Show a tile type | setFrame(tile.type) | sprite.texture = textures[tile.type] |
| Input | this.input.on('pointerdown', ...) | usePointer().onDown(...) |
| Tweens | this.add.tween({ targets, y, duration }) | useTween(): animates a number, onUpdate copies it into transform.y |
| Time unit | milliseconds | seconds |
| Bounce | 'Bounce.easeOut' | easeOutBounce |
| Delays | this.time.addEvent({ delay: 5000, ... }) | useTimer().after(5, ...) |
| Font | bitmap font loaded from font.png and font.fnt | the 8 pixel font built into the engine |
The biggest change in thinking is the tween. In Phaser you tell a tween which object and which property to animate. In NacatamalOn a tween animates a plain number and hands it to you every frame in onUpdate, and you put it wherever it belongs: a sprite’s x, its y, or the alpha of its tint. It is a few more characters, and it means the same tween can move a sprite, fade it or widen a loading bar.
The other one is hooks. usePointer, useTween and useTimer can only be called while the scene is being built, so they sit at the top of the function and what they return is used later from the callbacks.
No image files
The Phaser version loads a spritesheet and a bitmap font. This one does not load a single image: the tiles and the sky behind the board are painted in code, pixel by pixel.
NacatamalOn has a small set of functions for that. createPixels makes a picture in memory, fillRect, fillGradient and friends paint on it, and createPixelTexture turns it into a texture a sprite can show. No <canvas> is involved, so the same code also runs on the engine’s native runtime and in a build script.
This is how a tile is painted: a dark block with cut corners, the base color, a light rim on the top and left edges and a small shine.
Every color goes through megaDrive() first, which snaps it to the Mega Drive’s 9-bit palette: 8 levels per channel. The reason becomes clear in the next section.
The 1997 look
The game is 384 × 216 pixels, scaled up by whole multiples to fit the window, and every frame goes through two screen effects added with usePostProcess. Order matters: each effect reads what the one before it produced.
Dithering first. COLOR_LEVELS.genesis cuts each color channel to 8 levels, the color depth of the Sega Mega Drive, and hides the steps with an ordered pattern. The dither nudges every pixel by up to half a level before rounding, so a color sitting exactly on a level stays flat. That is why every color of tiles, board and text is snapped with megaDrive(): they stay clean, while the smooth sky gradient breaks into bands and the tiles turn into a checkered pattern as they fade out.
The CRT last. It starts from the consumer preset, the TV in the living room, with the phosphor mask, noise and flicker switched off and a gentle curvature. The game is drawn with pixelRatio: 2, two real pixels per game pixel, so each scanline is drawn as a beam with a gap instead of just darkening every other row.
The curved glass leaves an area outside the picture, and the built-in shader paints it black, with no option to change it. crtWithBezel is a small wrapper around crt() that replaces that black, in both the WebGPU and the WebGL2 shader, with a color of my choice: the same color as the page, so the tube seems to sit on it. If a future version of the engine changes those lines, the wrapper throws an error instead of silently going back to black.
As for the keys, two lines in a useUpdate callback flip the enabled property of each effect when C or D is pressed.
Three things worth knowing
Working with a 0.x engine means finding a few rough edges. None of them stopped the game, and all of them are easy to work around.
The built-in font is a data URL. The engine ships its font as a PNG in base64 inside its own code, and loads it with fetch() on a data: URL. Pages with a strict Content Security Policy block that kind of request, and when that happens no text is drawn at all, while sprites keep working. If your host does this, load the same font from a real file with useLoadBitmapFont.
WebGPU and WebGL2 round differently. The dither shader rounds with round() in WGSL and with floor(x + 0.5) in GLSL. WGSL rounds halves to the even number, so at full strength a pure white pixel drops one level once every 16 pixels on WebGPU, and never on WebGL2. Setting the dither strength to 0.96 keeps the pattern off the exact half step, and nobody can tell the difference.
It is written for AI assistants too. The npm package contains llms.txt and llms-full.txt, a short guide and the complete reference of every export, always matching the installed version. With a library this young, which no assistant has seen in its training data, this makes a real difference.
The complete game.ts
Have a look at the complete game.ts file. The comments explain what the engine does where it is not obvious:
import * as NacatamalOn from 'nacatamalon';
import * as Config from '../config';
import * as Utils from '../utils';
import { SameGame } from '../vendor/samegame/SameGame';
import type { SameGameBoardChange, SameGameBoardStats } from '../vendor/samegame/SameGame';
/**
* a tile on the board
*
* type the logical value the SameGame engine compares to build regions
* sprite the sprite that shows it on screen
*
* the engine only cares about type, the scene only moves the sprite:
* logic and rendering never mix
*/
interface Tile {
type: number;
sprite: NacatamalOn.TSprite;
}
/**
* what is saved in localStorage
*
* score best score ever reached in this browser
*/
type BestScore = {
score: number;
};
/**
* tile width and height, in pixels
*/
const tileSize: number = 16;
/**
* board size, in tiles
*/
const rows: number = 10;
const cols: number = 20;
/**
* localStorage key the best score is saved under
*/
const localStorageName: string = 'samegame';
/**
* position of the board's top-left corner, in pixels
*
* offsetX centres the 320 pixel wide board in the 384 pixel wide game,
* offsetY leaves room for the score above it
*/
const offsetX: number = 32;
const offsetY: number = 28;
/**
* the game scene
*
* a scene is a function that runs once, when the scene starts: it creates everything,
* registers what happens later (input, tweens, timers), then returns createScene()
*
* order of the scene:
* - tile textures, screen effects and backdrop
* - best score, texts and game state
* - sprite pool, tile generator and comparator
* - the board and the input
* - the move sequence: removeBlocks -> makeBlocksFall -> compactTable -> newMove
*/
export const Game: NacatamalOn.TSceneFn = (): NacatamalOn.TScene => {
/**
* hooks must be called while the scene is being built, never later:
* what they return is kept and used from callbacks
*
* input mouse and touch
* keyboard keys, to switch the screen effects
* tween starts tweens, which animate a number over time
* timer runs code later, in game time
*/
const input: NacatamalOn.TPointerHandle = NacatamalOn.usePointer();
const keyboard: NacatamalOn.TKeyboard = NacatamalOn.useKeyboard();
const tween: NacatamalOn.TTweenStarter = NacatamalOn.useTween();
const timer: NacatamalOn.TTimer = NacatamalOn.useTimer();
/**
* one texture per tile type, painted in code instead of loaded from a file
*/
const textures: NacatamalOn.TTexture[] = [];
for (let i: number = 0; i < 4; i++) {
textures.push(NacatamalOn.createPixelTexture(Utils.paintTile(i, tileSize)));
}
/**
* screen effects, applied to the whole frame in the order they are added
*
* dither first: cuts every colour channel to the Mega Drive's 8 levels,
* hiding the steps with an ordered pattern
*
* CRT last: draws the result on a curved tube with scanlines,
* with a custom colour around the glass instead of black
*/
const ditherEffect: NacatamalOn.TPostEffect = NacatamalOn.usePostProcess(NacatamalOn.dither({
levels: NacatamalOn.COLOR_LEVELS.genesis,
strength: Config.DITHER_STRENGTH,
}));
const crtEffect: NacatamalOn.TPostEffect = NacatamalOn.usePostProcess(Utils.crtWithBezel({
...NacatamalOn.CRT_PRESETS.consumer,
mask: 0,
noise: 0,
flicker: 0,
curvature: 0.05,
brightness: 1.2,
}, NacatamalOn.getColor(Config.COLORS.bezel)));
/**
* every frame: C switches the CRT off and on, D the dither
*/
NacatamalOn.useUpdate((): void => {
if (keyboard.justPressed('c')) {
crtEffect.enabled = !crtEffect.enabled;
}
if (keyboard.justPressed('d')) {
ditherEffect.enabled = !ditherEffect.enabled;
}
});
/**
* the sky: a smooth gradient covering the whole game, for the dither to break into bands
*
* anchor { x: 0, y: 0 } places a sprite by its top-left corner instead of its centre
*/
NacatamalOn.createSprite({
width: Config.WIDTH,
height: Config.HEIGHT,
texture: NacatamalOn.createPixelTexture(Utils.paintBackdrop(Config.WIDTH, Config.HEIGHT)),
anchor: {
x: 0,
y: 0,
},
});
/**
* the board's frame and panel: two plain rectangles, a sprite with no texture
* is drawn in its tint colour
*/
NacatamalOn.createSprite({
width: cols * tileSize + 4,
height: rows * tileSize + 4,
tint: Utils.megaDrive(Config.COLORS.boardEdge),
anchor: {
x: 0,
y: 0,
},
transform: {
x: offsetX - 2,
y: offsetY - 2,
},
});
NacatamalOn.createSprite({
width: cols * tileSize + 2,
height: rows * tileSize + 2,
tint: Utils.megaDrive(Config.COLORS.board),
anchor: {
x: 0,
y: 0,
},
transform: {
x: offsetX - 1,
y: offsetY - 1,
},
});
/**
* best score saved in localStorage, 0 if nothing was saved yet
*/
const rawData: string | null = localStorage.getItem(localStorageName);
const savedBestScore: BestScore | null = rawData !== null ? JSON.parse(rawData) : null;
let bestScore: number = savedBestScore !== null ? savedBestScore.score : 0;
/**
* texts, with the font built into the engine: 8 pixels tall, drawn at 16 to stay crisp
*
* scoreText current score, top left
* bestScoreText best score, top right: anchor x 1 puts its right edge on x
* titleText game name, level and messages, centred below the board
*/
const scoreText: NacatamalOn.TText = NacatamalOn.createText({
text: '0',
style: {
fontSize: 16,
},
tint: Utils.megaDrive(Config.COLORS.text),
transform: {
x: offsetX,
y: 6,
},
});
const bestScoreText: NacatamalOn.TText = NacatamalOn.createText({
text: 'BEST: ' + bestScore,
style: {
fontSize: 16,
},
tint: Utils.megaDrive(Config.COLORS.textDim),
anchor: {
x: 1,
y: 0,
},
transform: {
x: Config.WIDTH - offsetX,
y: 6,
},
});
const titleText: NacatamalOn.TText = NacatamalOn.createText({
text: 'SAMEGAME',
style: {
fontSize: 16,
},
tint: Utils.megaDrive(Config.COLORS.text),
anchor: {
x: 0.5,
y: 0.5,
},
transform: {
x: Config.WIDTH / 2,
y: (offsetY + rows * tileSize + Config.HEIGHT) / 2,
},
});
/**
* game state
*
* score current score
* level current level, multiplies the points of every move
* canPick false while a move is animating, so clicks are ignored
*/
let score: number = 0;
let level: number = 1;
let canPick: boolean = true;
/**
* every sprite the board can ever need, created once and hidden
*
* sprites are taken from the pool when a tile is generated and given back when it is
* removed, instead of being created and destroyed continuously
*
* each sprite gets its own tint, because the fade animates the alpha of each one
*/
const spritePool: NacatamalOn.TSprite[] = [];
for (let i: number = 0; i < rows * cols; i++) {
const sprite: NacatamalOn.TSprite = NacatamalOn.createSprite({
width: tileSize,
height: tileSize,
texture: textures[0],
tint: Utils.megaDrive('#ffffff'),
visible: false,
});
spritePool.push(sprite);
}
/**
* creates a tile: called by the SameGame engine for every cell of a new board
*
* @returns a tile with a random type and a sprite taken from the pool, visible and opaque
*/
const generator = (): Tile => {
const type: number = Math.floor(Math.random() * 4);
const sprite: NacatamalOn.TSprite = spritePool.pop() as NacatamalOn.TSprite;
sprite.visible = true;
sprite.tint.a = 1;
return { type, sprite };
};
/**
* tells the SameGame engine when two tiles belong to the same region
*
* @param a first tile, or null for an empty cell
* @param b second tile, or null for an empty cell
* @returns true if both cells hold a tile and the tiles have the same type
*/
const equals = (a: Tile | null, b: Tile | null): boolean => {
return a !== null && b !== null && a.type === b.type;
};
/**
* the board: rows x cols, tiles fall down, empty columns are compacted,
* and the first board always has at least one move
*/
let game: SameGame<Tile> = new SameGame(rows, cols, 'down', 'normal', generator, true, equals);
/**
* places every tile's sprite on its cell and shows the texture of its type
*
* called once for each new board: after that, sprites are only moved by tweens
*/
const drawBoard = (): void => {
const grid: (Tile | null)[][] = game.getGrid();
for (let row: number = 0; row < rows; row++) {
for (let col: number = 0; col < cols; col++) {
const tile: Tile | null = grid[row][col];
if (tile) {
/**
* sprites are placed by their centre, hence the half tile
*/
tile.sprite.transform.x = offsetX + tileSize / 2 + col * tileSize;
tile.sprite.transform.y = offsetY + tileSize / 2 + row * tileSize;
tile.sprite.texture = textures[tile.type];
}
}
}
};
drawBoard();
/**
* a click or a tap anywhere in the game
*
* the pointer position becomes a cell, and the SameGame engine removes the region there:
* outside the board or on a single tile nothing is removed, and the returned change is empty
*/
input.onDown((pointer: NacatamalOn.TPointerInfo): void => {
if (!canPick) {
return;
}
canPick = false;
const col: number = Math.floor((pointer.worldX - offsetX) / tileSize);
const row: number = Math.floor((pointer.worldY - offsetY) / tileSize);
const change: SameGameBoardChange<Tile> = game.removeRegion(row, col);
removeBlocks(change);
});
/**
* first step of a move: scores the removed tiles and fades them out
*
* points are level x n x (n - 1), where n is the number of removed tiles,
* so bigger regions are worth much more
*
* @param change what the SameGame engine changed on the board
*/
const removeBlocks = (change: SameGameBoardChange<Tile>): void => {
if (change.removed.length > 0) {
/**
* the first move of a game replaces the title with the level
*/
if (score === 0) {
titleText.text = 'LEVEL 1';
}
score += level * change.removed.length * (change.removed.length - 1);
scoreText.text = score.toString();
if (score > bestScore) {
bestScore = score;
bestScoreText.text = 'BEST: ' + score.toString();
localStorage.setItem(localStorageName, JSON.stringify({ score: bestScore }));
}
const spritesToFade: NacatamalOn.TSprite[] = [];
change.removed.forEach((item: SameGameBoardChange<Tile>['removed'][number]) => {
const tile: Tile | null = item.value;
if (tile) {
spritesToFade.push(tile.sprite);
}
});
/**
* a tween animates a number, not an object: onUpdate receives the value
* every frame and copies it into the alpha of each sprite
*/
tween({
from: 1,
to: 0,
duration: 0.5,
onUpdate: (alpha: number): void => {
spritesToFade.forEach((s: NacatamalOn.TSprite) => {
s.tint.a = alpha;
});
},
onComplete: (): void => {
/**
* faded sprites are hidden and go back to the pool
*/
spritesToFade.forEach((s: NacatamalOn.TSprite) => {
s.visible = false;
spritePool.push(s);
});
makeBlocksFall(change);
},
});
}
else {
/**
* nothing removed: the player can click again
*/
canPick = true;
}
};
/**
* second step of a move: tiles above the removed ones fall down
*
* each tile falls 0.05 seconds per row, so every tile moves at the same speed;
* the next step starts when the last tween ends
*
* @param change what the SameGame engine changed on the board
*/
const makeBlocksFall = (change: SameGameBoardChange<Tile>): void => {
if (change.moved.length > 0) {
let tweens: number = 0;
change.moved.forEach((item: SameGameBoardChange<Tile>['moved'][number]) => {
const tile: Tile | null = item.value;
if (tile) {
const startY: number = tile.sprite.transform.y;
tween({
from: startY,
to: startY + (item.to.row - item.from.row) * tileSize,
duration: 0.05 * (item.to.row - item.from.row),
onUpdate: (y: number): void => {
tile.sprite.transform.y = y;
},
onComplete: (): void => {
tweens++;
if (tweens === change.moved.length) {
compactTable(change);
}
},
});
}
});
}
else {
compactTable(change);
}
};
/**
* third step of a move: whole columns slide left to fill empty ones, with a bounce
*
* each column takes 0.5 seconds per column it travels;
* the next step starts when the last tween ends
*
* @param change what the SameGame engine changed on the board
*/
const compactTable = (change: SameGameBoardChange<Tile>): void => {
if (change.shifted.length > 0) {
let tweens: number = 0;
change.shifted.forEach((item: SameGameBoardChange<Tile>['shifted'][number]) => {
const tile: Tile | null = item.value;
if (tile) {
const startX: number = tile.sprite.transform.x;
tween({
from: startX,
to: startX + (item.to.col - item.from.col) * tileSize,
duration: 0.5 * (item.from.col - item.to.col),
ease: NacatamalOn.easeOutBounce,
onUpdate: (x: number): void => {
tile.sprite.transform.x = x;
},
onComplete: (): void => {
tweens++;
if (tweens === change.shifted.length) {
newMove();
}
},
});
}
});
}
else {
newMove();
}
};
/**
* last step of a move: decides what happens next
*
* - moves left: the player can click again
* - no moves and tiles left: game over, after 5 seconds a new game starts from level 1
* - no tiles left: level cleared, after 3 seconds the next level starts, keeping the score
*/
const newMove = (): void => {
if (game.hasMoves()) {
canPick = true;
}
else {
const stats: SameGameBoardStats = game.getBoardStats();
if (stats.tiles > 0) {
titleText.text = 'NO MORE MOVES';
timer.after(5, (): void => {
titleText.text = 'SAMEGAME';
/**
* the tiles still on the board give their sprites back to the pool
* before the new board takes them again
*/
const remainingTiles: Tile[] = game.getAllTiles();
remainingTiles.forEach((tile: Tile) => {
tile.sprite.visible = false;
spritePool.push(tile.sprite);
});
game = new SameGame(rows, cols, 'down', 'normal', generator, true, equals);
canPick = true;
score = 0;
level = 1;
scoreText.text = '0';
drawBoard();
});
}
else {
titleText.text = 'CONGRATULATIONS';
timer.after(3, (): void => {
level++;
titleText.text = 'LEVEL ' + level.toString();
game = new SameGame(rows, cols, 'down', 'normal', generator, true, equals);
canPick = true;
drawBoard();
});
}
}
};
return NacatamalOn.createScene();
};All the game logic lives in the SameGame engine, so the scene only deals with sprites, tweens and texts.
Download the source code
The complete project, powered by Vite, is available for download here or at the itch.io page of the game: the NacatamalOn scene, the SameGame engine, the functions that paint the art and a README that walks you through all of it.
Run npm install, then npm run dev, and you are playing.
It is a good starting point if you want to change the board size, add a fifth tile color, try the SNES color depth or another CRT preset, or reuse the engine with a renderer of your own.
- Play the NacatamalOn version and download the source: itch.io page of the game
- Play the Phaser 4 version: itch.io
- How the Phaser 4 version was built: read the tutorial
- The headless engine, with docs and examples: samegame-engine on GitHub
- The renderer: NacatamalOn, with its docs and source
The font, Nacatamal Arcade, is built into NacatamalOn and derived from Press Start 2P, under the SIL Open Font License 1.1.
One engine, two renderers. Which renderer should I try next?