TL;DR
Primitiv Engine is a multiplayer-first TypeScript game engine (pre-alpha). The server sends draw commands, the client just displays (think server-side rendering, but for games). No prediction, no reconciliation, no desync. Write one class, run it standalone in the browser or on a Node.js server with connected clients: same code, zero changes.
Website - Try the live examples
What it looks like
The display is a grid of cells (up to 256×256). Each cell holds three things:
- a character (CP437 by default:
║═╗,░▒▓█,♥♦♣♠, or a custom sprite if you load your own atlas) - a foreground color
- a background color
To draw on that grid, you issue draw commands:
const orders = [
OrderBuilder.text(8, 0, `Score: ${data.score}`, TEXT_COLOR, BG_COLOR),
OrderBuilder.char(data.food.x, data.food.y, '♦', FOOD_COLOR, BG_COLOR),
OrderBuilder.polyline(data.snake, '█', SNAKE_COLOR),
OrderBuilder.char(data.snake[0].x, data.snake[0].y, '@', SNAKE_COLOR, BG_COLOR)
];
layer.setOrders(orders);

From the Primitiv Minimal Snake example
These commands are grouped into layers: independent planes stacked on top of each other, each with its own position, z-index, and size. You might have one layer for the background, one for the UI, one for the player. Layers are sent incrementally: only those that changed are retransmitted each tick. Each layer holds up to 255 orders; if you're hitting that limit, it's usually a sign the layer is doing too much, should be split, or is using the wrong kind of order.
Since every cell has its own independent colors, the grid also works as an array of colored "fat pixels", which makes it suitable for various pseudo-3D techniques.
There are many more draw commands (circles, ellipses, triangles, polygons, sprites, bitmasks, dot clouds), but the idea is always the same: you describe what to draw, the engine handles the rest. You can also send a full pre-computed frame as a single order, useful for static backgrounds that don't change. Sending a full frame every tick is possible too, but it comes at a higher network cost.
These commands are declarative. In multiplayer, they're sent from the server to the client over the network. The client renders them and sends back inputs. It has no game logic, no authority, no prediction.
The same code also runs entirely in the browser with no server at all: that's standalone mode. The live examples above use it: no backend, just your browser.
Multiplayer-first, even for solo games
Your game is one class implementing IApplication. Every connecting player gets their own session: their own display, their own layers, their own inputs. A solo game works out of the box; each player simply runs their own instance.
But the moment you want players to interact, there's no refactor. Share a variable in update(), read other players' positions in updateUser(), and they see each other. That's it. No networking layer to add, no protocol to design, no sync to debug. Multiplayer is the default: solo is just multiplayer with one player.
// Standalone: everything runs in the browser
const client = new ClientRuntime({
displays: [{ displayId: 0, container: document.getElementById('game')! }],
mode: 'standalone',
standalone: { application: new MyGame() },
});
For multiplayer, replace with a RuntimeServer on Node.js and a ClientRuntime in connected mode. The IApplication class doesn't change at all, including imports. @primitiv/engine runs identically in both environments.
Standalone for developing and distributing solo games. Connected for multiplayer. Transport: WebSocket (uWebSockets.js) or WebRTC, both with the same API. Client bundle: currently < 85 KB gzip. The renderer is either Canvas 2D or WebGL, your choice when creating the ClientRuntime.
Because the server computes each player's view individually, the architecture is naturally suited to patterns like fog of war, asymmetric perspectives, and spectator mode.
It also means render generation should stay cheap: precompute or cache what does not change, keep static content in stable layers, and only update what actually moves or changes at a given tick.
That is also why some of the 3D showcases on the examples site should be read as rendering demos first. They work well in standalone mode, but they are not representative of the kind of view generation you would want to run server-side at scale.
What kind of projects
Roguelikes, tactical RPGs, puzzle games, party games, MUDs, turn-based strategy, interactive fiction, game jam entries. But not only games: the same model also fits solo or collaborative applications, shared interactive tools, installations, and animated backgrounds or ambient visual layers for a web page or product experience. In short, it shines when readability, deterministic behavior, and a clear shared view matter more than ultra-smooth, sub-pixel animation.
It is also a good fit for rapid prototyping: you can test a mechanic quickly, validate whether it works, and keep building on the same foundation if it does.
The grid-based display is strongest when the world is discrete, positions stay readable, and the visuals benefit more from clear symbols than from sub-pixel motion.
On the multiplayer side, Primitiv deliberately does not use client-side prediction or reconciliation. The server is authoritative and the client is just a renderer. That is what removes desync and netcode complexity, but it also means latency is felt more directly.
That said, you can absolutely build something fast and reactive in standalone mode. A twitchy Pong, an arcade score attack, or a nervous local action prototype can work very well when everything runs in the browser with no network in the loop.

From the Primitiv Pong example
So it is not a good fit for competitive FPS, fast-paced action games, or experiences built around sub-pixel animation at 60+ Hz. Those usually benefit from prediction, reconciliation, and a rendering model less constrained than a cell grid.
Beyond rendering
The engine also takes care of:
- Input: keyboard, mouse, gamepad, touch, touch zones, delivered as a ready-to-read binding system
- Audio: MP3 playback, 2D spatialization, pitch, low-pass filter, reverb
- Haptics: gamepad rumble, mobile vibration
- Bridge channel: bidirectional messaging between the server and the web page wrapping the client, over the already-open game connection, useful for dynamic UI reacting to game events
- Custom tileset: CP437 (8×8) by default; add blocks of 256 glyphs, load custom fonts; 16-bit mode per layer when you need more than 256 characters (unlocking custom sprites)
Pre-alpha, Apache 2.0
API will change. The npm packages are already distributed under Apache 2.0, and the main source code will be made public at alpha. Until then, I want the API, project structure, and examples to settle a bit so the first public release is something people can actually build on. The examples repo is the documentation for now.
On testing: standalone mode is well covered: it runs on an internal loopback, so it exercises the same code paths as connected mode. Connected mode itself hasn't been tested at scale yet; it works with a few players, but real multiplayer stress testing hasn't happened.
One thing that won't change: the scope. Keeping a minimal footprint is a deliberate design goal: the engine is meant to stay focused, not grow into a framework that tries to do everything.
How it works under the hood
If you just want to try it, the sections above and the examples are enough. What follows explains the architecture.
The core idea
Adding multiplayer to a game usually means: state synchronization, client prediction, reconciliation, conflict resolution. Every layer makes the code more fragile.
Primitiv removes the root cause: there is no shared state to synchronize. The server doesn't tell clients "the player is at (12, 7) with 3 HP". It tells them "draw a green @ at column 12, row 7". The client is a display terminal. The server owns everything.
If you've worked with web apps, the model is familiar: the server renders, the client displays. Same idea, but instead of HTML, the server sends compact binary draw commands, and instead of clicks, the client sends back gamepad, keyboard, and mouse inputs.
No shared state → no desync. No game logic on the client → no cheating. Same code for solo and multiplayer → no porting layer.
The game loop
Your entire game is one class with five methods:
import {
Engine,
Layer,
OrderBuilder,
User,
Display,
Vector2,
InputDeviceType,
KeyboardInput,
type IApplication,
type IRuntime,
} from '@primitiv/engine';
interface PlayerData {
layer: Layer;
x: number;
y: number;
}
export class MyGame implements IApplication<Engine, User<PlayerData>> {
async init(runtime: IRuntime, engine: Engine) {
engine.loadPaletteToSlot(0, [
{ colorId: 0, r: 0, g: 0, b: 0, a: 255 },
{ colorId: 1, r: 255, g: 255, b: 255, a: 255 },
{ colorId: 2, r: 0, g: 255, b: 0, a: 255 }
]);
runtime.setTickRate(20);
}
async initUser(_runtime: IRuntime, _engine: Engine, user: User<PlayerData>) {
const display = new Display(0, 40, 25);
user.addDisplay(display);
display.switchPalette(0);
const layer = new Layer(new Vector2(0, 0), 0, 40, 25);
user.addLayer(layer);
user.data = { layer, x: 20, y: 12 };
const registry = user.getInputBindingRegistry();
registry.defineAxis(0, 'mx', [{
sourceId: 0, type: InputDeviceType.Keyboard,
negativeKey: KeyboardInput.ArrowLeft, positiveKey: KeyboardInput.ArrowRight
}]);
registry.defineAxis(1, 'my', [{
sourceId: 1, type: InputDeviceType.Keyboard,
negativeKey: KeyboardInput.ArrowUp, positiveKey: KeyboardInput.ArrowDown
}]);
}
update() {
// Update common to all users.
}
updateUser(_runtime: IRuntime, _engine: Engine, user: User<PlayerData>) {
const d = user.data;
d.x = Math.max(0, Math.min(39, d.x + user.getAxis('mx')));
d.y = Math.max(0, Math.min(24, d.y + user.getAxis('my')));
// Set render for this user.
d.layer.setOrders([
OrderBuilder.text(0, 0, 'Arrow keys to move', 1, 255),
OrderBuilder.char(d.x, d.y, '@', 2, 255),
]);
}
async destroyUser() {
// Clean user data, save in db...
}
}
-
initruns once at startup. -
initUserruns when a player connects: create a display, a layer, define their input bindings. -
updateruns every tick for global game logic (physics, AI, world state). -
updateUserruns every tick per player: read inputs, update state, produce draw commands. -
destroyUserruns when a player disconnects.
Why the grid makes the network work
The grid constraint is what makes the network model viable. Draw commands are compact: a fill, a rect, a text each describes hundreds of cells in a few bytes. A Bomberman-style game uses ~2 KB/s per player at 20 Hz.
Layers are incremental: unchanged layers aren't resent. The binary protocol supports fragmentation when needed, but in practice most ticks fit in a single network packet.
Because rendering is computed per player, scaling well also means keeping updateUser() and view generation lightweight.
Get started
npx @primitiv/cli@latest create my-game
Website · Code examples · Live examples · Discord
Apache 2.0: the published packages are already usable in commercial projects.
At this stage, what matters most is a small group of developers building real things on top of it and sharing feedback on Discord. Not engine contributors yet: people making actual projects, finding what works, what breaks, and what this model is unexpectedly good at. And if you just have thoughts after reading, comments are welcome too.


Top comments (0)