Pixel dioramas
Polished isometric pixel-art places for a browser game: describe a ground, place boxes, spheres, trees and people, and get hue-shifted light, dither, hard shadows and wind. It's Amir Mushich's Pixel Plays, with his five small games to play.
The renderer, the look and all five games are the work of Amir Mushich: Pixel Plays, MIT, © 2026 Amir Mushich. Concept by abhi, game by AmirMushich. Go and play the originals, with their music. The style itself is his too, and his README credits its visual reference: shawn.'s Small plays, with "no assets or subjects copied". Patch only tidied his engine into something you can call, and added a sun you can turn, a wind strength and times of day.
It's its own small renderer, not a post effect. It draws into a low-resolution picture of its own on a plain 2D canvas, with no WebGL. It can't draw into a three.js game or restyle one: a whole game has to be drawn with it. Its camera is one fixed 30-degree view, and the world doesn't scroll. That suits one small place at a time.
Make a diorama
Copy engine/diorama.js into your game. It's one file with no dependencies.
<div style="position: fixed; inset: 0; background: #fff"><canvas id="c" style="position: absolute; image-rendering: pixelated"></canvas></div>
import { createDiorama, fitCanvas, hash, noise, rampOf, rgb, clamp, KIND_GRASS, GRASS } from './engine/diorama.js';
const canvas = document.getElementById('c');
const d = createDiorama(canvas, { originY: 20, shadowX: 1.1, shadowY: -.3 });
// 1. The ground: a colour for each pixel, or 0 for nothing. Keep tones close, and wear the edge away into the white.
d.paintGround((x, y, ix, iy, i) => {
const edge = Math.hypot(x / 14, y / 9) + (noise(x * 1.3, y * 1.3) - .5) * .13;
if (edge > 1.04 || (edge > .93 && hash(ix, iy) < (edge - .93) / .11)) return 0;
d.kinds[i] = KIND_GRASS; // grass grows tufts that sway
const t = noise(x * .9, y * .9) * 1.15 + (hash(ix, iy) - .5) * .55 - .08;
return rgb(...GRASS[clamp(Math.floor(t * 4.2), 0, 3)]);
});
// 2. What stands still. Draw as much as you like: it's baked once and costs nothing per frame.
d.box(-3, -5, 0, 1, -2, 2.4, [214, 196, 160], 1); // x0 y0 z0 x1 y1 z1, a colour, a seed for its grain
d.sphere(3, 2, .3, .7, [216, 206, 184], 'rough'); // a boulder
d.tree(-7, 2, .8, 300); // x, y, scale, leaves
for (let x = -3; x <= 1; x += .5) for (let y = -5; y <= -2; y += .5) for (let z = .3; z <= 2.4; z += .7) d.castShadow(x, y, z, .55);
d.castShadow(3, 2, .4, .7);
d.finishBake();
// 3. Scale it up with sharp pixels, on the box around what you drew.
fitCanvas(canvas, { x: 0, y: 0, width: innerWidth, height: innerHeight }, d.contentBox());
// 4. Every frame: what moves goes in objects. Grass, leaves, shadows and edges happen by themselves.
const person = { shirt: [188, 60, 44], stripe: [240, 232, 214], legs: [48, 47, 58], hair: [38, 28, 24], skin: [222, 170, 132] };
function frame(ms) {
const time = ms / 1000;
d.render({
time,
objects() {
d.figure(4 + Math.sin(time) * 2, 4, 0, { armR: .2, stance: .15 }, person);
d.sphere(1, 5, .3, .3, [232, 200, 62], 'gloss');
d.castShadow(1, 5, 0, .3, 2, 0);
},
});
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
World units: x runs down and to the right, y down and to the left, z up. A person is about 2.8 tall, and one unit is 10 pixels across. The default picture is 360 x 280 and shows about 30 units across.
The light comes from the far left: on a box, the top is brightest, the +y face (towards you on the left) is in between and the +x face (towards you on the right) is darkest. Keep the same order on surfaces you plot yourself.
What to call
| Call | What it does |
|---|---|
createDiorama(canvas, options) | Any canvas. Options: width, height (360 x 280; a bigger picture shows more world), originY, shadowX, shadowY (how far shadows fall per unit of height). |
paintGround(colorOf) | Starts the bake. colorOf(x, y, ix, iy, i) returns a colour or 0. Set kinds[i] = KIND_GRASS for swaying grass. Other numbers are yours, to mark water or clay. A plain 1 keeps tufts from growing over that pixel. |
box, sphere, capsule, plot | Solids and single world pixels. sphere takes a mode: 'rough' for stone and leaves, 'gloss' for a highlight, 'solid' for flat bands. capsule(a, b, r0, r1, colorOf, mode) picks its colour by height, so a ringed trunk is one line. |
tree(x, y, scale, leaves, seed) | A swaying tree. addLeaf makes your own swaying plant: games/mini-ramp.js grows palms with it. |
figure(x, y, yaw, pose, palette) | A person. pose: z, scale, crouch, lean, stance, armR, armL. Colours: shirt, stripe, legs, hair, skin. Returns the right hand's position, to put something in it. There's no sitting pose: crouch: 1.3 with a small negative z sits someone on a log. For one in the bake, clear pendingShadows first and call flushShadows() after. |
dynamicAdd(a, b, r0, r1, colorOf) | One bone of something else that moves, with its shadow: a rider, a board, a racket. |
castShadow(x, y, z, r, strength, dither) | A shadow from a ball at that height. Nothing casts one by itself except figures and leaves, so give your solids some, as above: a grid of casts inside a box is how the games do it. |
finishBake() | Ends the bake. markBaked and clearMarks then leave marks on it, like tracks in sand (games/balote.js). contentBox() gives { x0, y0, x1, y1 }, the pixels of what you drew, for fitCanvas. |
render({ time, objects, surface, flush, shadowsOn, overlay }) | One frame. surface paints on the ground (water, firelight), overlay goes straight onto the picture (rain, drawArrow, drawDots). shadowsOn: 'ground' keeps moving shadows off masonry. |
pix, zb, groundZ, ground, W, H | The picture itself, while you're in surface or overlay: a pixel i = iy * W + ix is bare ground when zb[i] === groundZ[i]. |
setSun(angle, length), setWind(amount), setGrade(look) | Change it while it runs, cheaply enough for a slider. Past wind 1, bits of leaf blow across the place. grades.js has the times of day. |
project(x, y, z), unproject(sx, sy) | World to picture pixels and back, for clicks. fitCanvas scales it, so divide clicks by the canvas's on-screen size, as toPicture in demo.js does. |
rampOf(color), hash, noise, bayerAt, rgb, clamp, lerp, ease | A colour's five hue-shifted shades, steady noise, and small helpers. |
Keeping the look
These are Amir's rules, from his README. They are most of why it looks the way it does:
- One small place floating on pure white, with no interface around it.
- Long, hard shadows, all falling the same way. One readable physical action per game.
- Large flat surfaces use closely spaced tones: wide steps make the dither read as a checkerboard. See
SAND_TONESingames/balote.jsandCLAY_TONESingames/tie-break.js. - Colours should come from
rampOf, so shadows drift to violet and lights to warm yellow. - Characters stay sharp: flat bands with no dither, and patterns at least 3 pixels wide.
- Motion stays in the plants, in whole pixels at 8 frames a second. It stops for people who ask their system for reduced motion.
The five games are worked examples
Each is one file you can read top to bottom, with the same shape: constants, groundColor, a bake, a game state G, then update and draw.
| Game | Look here for |
|---|---|
games/stari-most.js | Masonry, arches, towers and roofs by plot; a river with moving streaks; a crowd that wanders; splashes. The richest scenery. |
games/mini-ramp.js | A curved surface by plot, shadows that bend over it (flush), palms from addLeaf, and a rider by dynamicAdd. |
games/tie-break.js | Court lines, a chain-link fence and a net; two figures with rackets; a bot; marks on the clay. |
games/balote.js | The plainest one, so start here: a court, a bench, a tree, balls with real collisions, a throw by dragging. |
games/laundry.js | A building dissolving into white, cloth that waves, picking things with a pixel buffer, and a storm graded over the whole picture. |
To start something new, copy games/balote.js, and replace its groundColor, its bake and then its rules. demo.js shows the loop around a game: sharp scaling, input, and pausing when it can't be seen.
There's no wedge or roof primitive: make slopes by plot in two loops, as roof in games/stari-most.js does. examples/campsite/ is a plain diorama with no game, about 100 lines: a tent, a fire and a person. An agent made it from this README as a test of it, on its first try.
How much it costs
About 1 to 2 ms a frame on a fast PC, on one CPU core. A bake is about 30 ms, plus some the first time the browser runs it. The JavaScript is 90 KB (38 KB gzipped), with no images. A bigger picture costs more: it's every pixel, every frame. It runs on any graphics card, since it doesn't use one.
What you can't do
- Draw into three.js, or be a post effect in one. A shader with the same colour steps, dither and edges would be different work.
- Move the camera. It's a fixed 30-degree view, and the bake is one picture.
- Light things fully from a new side.
setSunswings every shadow and turns the light on figures and other moving things, but what's baked keeps its shading.
The demo
Pick a game on the page and play it right there. Each shows its controls in one small line, and Reset or Restart starts it over. Picking a game always starts it fresh.
For people (the block page shows these):
| id | Control | Type | Range | What it does |
|---|---|---|---|---|
game | Game | choice with pictures | Stari Most, Mini Ramp, Tie-Break, Balote, Rooftop Laundry | Which of the five plays. |
time | Time of day | choice with pictures | Day, Golden hour, Dusk, Night | A colour grade over the whole picture, and longer shadows late in the day. Day is Amir's picture untouched. |
wind | Wind | number | 0 to 3 ×, default 1 | Grass, leaves and washing, and how it pushes the diver. Past 1, bits of leaf blow across. |
light | Light direction | number | -180 to 180°, default 0 | Turns the sun around the scene, and every shadow swings with it. |
Trigger: Restart (restart): starts this game again from the beginning.
For agents (in recipe.json with every range and default):
| id | Control | Type | Range | What it does |
|---|---|---|---|---|
shadow | Shadow length | number | 0.2 to 2.5 ×, default 1 | How long shadows fall. |
speed | Game speed | number | 0.25 to 2 ×, default 1 | Slows the game down or speeds it up. |
sound | Sound | toggle | default on | The games' sound effects, made in code. The frame has a sound on button too. |
What Patch changed in the games: the storm in Rooftop Laundry waits for your first touch, so opening it never starts with lost washing. A Balote turn can't wait for ever on a ball balanced on another. The wind control reaches the diver and the laundry. Everything else, rules and numbers, is Amir's. His background music is left out (see below), so the demo has only the sound effects.
Build it
npm install
npm run build # recipe.json, then dist/: the demo exactly as released, index.html at the top
| Path | What |
|---|---|
engine/diorama.js | The renderer: Amir's engine.js with no page globals, and the sun, wind and grade added |
games/*.js | His five games as modules, and speaker.js, the sound output they share |
demo.js, index.html, grades.js | The demo page and its times of day |
recipe.json, make-recipe.mjs | The release recipe (mode browser with its block section) |
thumbs/, cover.png, make-thumbs.mjs | The pictures, drawn by the engine |
examples/campsite/ | A plain diorama with no game |
test-look.mjs | Plays each game on this engine and on Amir's own engine.js with the same random numbers and input, and compares every pixel. All five are identical at the defaults. |
lib/patch-block.js | Patch's block demo helper, as served at patch.gg/patch-block.js |
Credits and licences
- Amir Mushich: Pixel Plays, at commit
0c2a70f, MIT. The renderer, the look and the five games. Keep the notice inLICENSE, and his credit line: "Concept by abhi / Game by AmirMushich". - Patch: the tidy, the demo, the sun, wind and times of day, and the pictures, MIT.
Code: MIT (LICENSE). Art: there are no art files: everything is drawn by code, so it's MIT too. The music is not included and not MIT. Pixel Plays' five tracks were generated with Suno and aren't licensed for reuse outside that project, so they aren't in this repo or its history. Add your own if you want music. Details are in LICENCE-NOTES.md.