thomaspiquetcom
ProjectsContact
← Projects
Type
Engine
Year
2026
Role
Author
Stack
TypeScript · WebGL · WebGPU · Node.js
Status
Pre-alpha · v0.23
License
Apache 2.0

Primitiv

A TypeScript engine for grid games. The server streams compact binary draw orders to the browser, and the same game can also run entirely in the browser.

Primitiv Craft: a voxel landscape with a lake and trees, ray-cast into coloured character cells.
Primitiv Craft, one of the live demos: a voxel world ray-cast into character cells.

Overview

Primitiv is an engine for grid games such as roguelikes, tactics and puzzle games. The screen is a grid of cells, and each cell holds a character and two colours. The server runs the game and sends the changes to that grid as compact binary draw orders, which the browser decodes and draws.

Sizes measured with Primitiv's encoder for this 8×4 scene, orders only: the layer and packet headers come on top. The browser rebuilds the cells from these orders.

One number per cell

Each cell is stored as one 32-bit number that holds its character, its text colour and its background colour. The whole screen is one long list of these numbers, a Uint32Array in JavaScript. The largest screen, 256×256 cells, fits in 256 KB, and storing cells this way creates no objects for the garbage collector to clean up.

pack
const packed = (bg << 24) | (fg << 16) | char;grid.data[y * width + x] = packed;
unpack
const char = packed & 0xffff;const fg   = (packed >>> 16) & 0xff;const bg   = (packed >>> 24) & 0xff;

With one number per cell, reading or changing a cell is a single array access. Colours are numbers from 0 to 255 that point into a palette of 256 colours; 255 is kept for transparent.

JavaScript's bit operations work on signed numbers, so the packed value can come out negative. The Uint32Array stores the same bits and reads them back as a positive number, so nothing is lost.

Drawing

Because a cell is one number, filling the whole screen with the same cell takes one call to the built-in fill function, which runs at native speed. Drawing a rectangle or a full frame of characters goes through the cells one by one in JavaScript and checks that each one is inside the screen. Filling is about 15 times faster per cell.

Screen of 256×256 cellsCellsTimeCells per second
Fill the whole screen with one cell65,5367.5 µs8.7 billion
Full frame, cell by cell65,536115.2 µs569 million
Rectangle of 128×128 cells, cell by cell16,38430.4 µs539 million

Layers

The screen is built from layers stacked on top of each other, for example a map at the bottom, characters above it and a HUD on top. Primitiv combines them starting with the top layer. Once a cell has a background, the layers below skip it. A character drawn without a background lets the background of a lower layer show through.

Each layer holds only its own cells. They are combined from the top down, so the map only fills what is still missing.
Screen of 80×50 cellsTime per frameShare of a frame at 60 fps
53 layers, 3 redrawn every frame0.37 ms2.2%
110 layers, 10 redrawn every frame0.79 ms4.7%
220 layers, 20 redrawn every frame1.56 ms9.4%

What the browser keeps

The browser keeps the drawn cells of every layer it receives. A layer's drawing is sent each time the game redraws it, which depends on the game: once for a map that never changes, regularly for an animation, or when something happens. Between two redraws, moving, hiding or showing the layer only sends its 11-byte header.

Under each tick, what the server sent for this layer. Tick 1 is an 80×50 dungeon map measured with Primitiv's encoder: 1,817 bytes with run-length encoding turned on, 12,012 without.

Over the network

Some layers are sent as a block of cells, for example a map drawn in one piece. By default, each cell goes out as three bytes: its character, its background and its text colour. A game can ask for the block to be compressed. Primitiv then splits it into three lists, all the characters, all the text colours and all the background colours, and compresses each list on its own, because they behave differently: a background is often one colour over large areas, while characters change from cell to cell.

The compression is run-length encoding: a value that repeats is sent once, with how many times it repeats. It only helps when values repeat. In automatic mode, Primitiv counts how often a list changes from one cell to the next, and compresses it only if it changes at fewer than half of its cells.

In automatic mode, a colour that repeats is sent once, with how many times it repeats. The characters change at almost every cell, so they are sent as they are. Sizes count the cell data only, without the order's few header bytes.

End to end

What happens to a frame outside the network: the server encodes the draw orders, then the browser decodes them and draws them into the layer's cells.

Encode → decode → drawTime
80×50 frame, 50 mixed orders9.72 µs
80×50 full frame of characters47.71 µs

A full frame takes under 0.05 ms. At 20 ticks per second, the server has 50 ms per tick.

All timings on this page were measured on 19 October 2026 with the repository's tinybench suite (commit 5955bc2), Node 24.13, AMD Ryzen 9 7950X. Each has a relative margin of error under 2%.

Status

Pre-alpha, with 1,311 tests passing. The complete browser client is 99.6 KB gzipped. The website runs ten live demos drawn in character cells, including Pong, a ray-cast voxel world and a fluid simulation.