- 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.

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.
- subFrameMultimap, 8×4101 B
- text" HP 9"11 B
- char@6 B
- charg6 B
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.
- background
- 2
- text colour
- 15
- character
- @ = 64
= 0x020F0040, one 32-bit number
const packed = (bg << 24) | (fg << 16) | char;grid.data[y * width + x] = packed;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 cells | Cells | Time | Cells per second |
|---|---|---|---|
| Fill the whole screen with one cell | 65,536 | 7.5 µs | 8.7 billion |
| Full frame, cell by cell | 65,536 | 115.2 µs | 569 million |
| Rectangle of 128×128 cells, cell by cell | 16,384 | 30.4 µs | 539 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.
| Screen of 80×50 cells | Time per frame | Share of a frame at 60 fps |
|---|---|---|
| 53 layers, 3 redrawn every frame | 0.37 ms | 2.2% |
| 110 layers, 10 redrawn every frame | 0.79 ms | 4.7% |
| 220 layers, 20 redrawn every frame | 1.56 ms | 9.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.
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.
20 bytes instead of 36
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 → draw | Time |
|---|---|
| 80×50 frame, 50 mixed orders | 9.72 µs |
| 80×50 full frame of characters | 47.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.