multiplayer-canvas
A multiplayer toroidal canvas for a personal site, and the primitives it is built from.
| Stars | Language | Updated | License |
|---|---|---|---|
| 0 | JavaScript | 2026-10-26 | MIT |
A site on a shared, wrapping canvas. Everyone who has the site open sees everyone else's cursor. Articles, case studies and experiments stand on the ground as tiles, and each one opens as a page over it.
The repo is two things:
- A site you can fork. Fastify and Nunjucks render the pages on the
server.
curlgets plain text and old browsers get HTML 3.2. It has an RSS feed, a sitemap,llms.txt, OG cards, a service worker and a passkey login for the owner. There is no client-side framework. - A set of primitives you can borrow. Pixel type, the wrapped camera,
live presence, rooms, a slime mould, a weather window and a small kit of
controls. Each one is a self-contained ES module or stylesheet under
primitives/, and you import each one by name.
Node 24 or newer.
Quickstart (fork the whole site)
npm ci && npm run devOpen http://localhost:3000. The server reloads when you save a file. Copying
.env.example to .env is optional, since every value in it has a default.
npm run check # lint, guard registry, design lint, format, tests
npm run verify:mutate # every registered guard proven to fail when its rule breaks
npm run build # minify and content-hash the static tree into dist/
npm start # serve; uses dist/ when it existsFolder map
primitives/ the borrowable parts, one module or stylesheet each
presence/ live cursors: browser client, server room, wire protocol
rooms/ private rooms, room URLs, following, shared views
slime/ the slime mould and its shared server clock
sky/ the weather window and its shared push
kit/ controls (kit.css), design tokens, small control scripts
src/ the site
server.js Fastify app: routes, CSP, static files, the import map
routes/ one file per surface (home, feed, work, about, rooms, auth)
templates/ Nunjucks: canvas shell, entry pages, terminal and retro
static/ the canvas app, site CSS and JS, fonts, images
lib/ services/ markdown, layout data, stores, the weather fetch
content/ the words: feed entries and the about text
examples/ one small page per primitive, plus a tiny server
scripts/ asset build, OG cards, image and token bakes, browser gates
test/ node:test suites, including text guards
site.config.js who the site is: name, URL, author, links, weather place
BIBLE.md the design contract: tokens, geometry, motion, the guardsThe site imports its own primitives by the same names you would
(multiplayer-canvas/torus). In Node that goes through package.json
exports. In the browser it goes through an import map the server writes
into every page.
Borrow a primitive
npm install github:4esv/multiplayer-canvasEvery import below is a path in package.json exports. The Node snippets
run as written, and test/readme.test.js runs them. The browser snippets
each have a working page in examples/. To try those pages, run
node examples/serve.mjs and open http://localhost:8080.
In a browser, serve node_modules/multiplayer-canvas/primitives/ at a URL
and map the names to it, one entry per name you use (the example pages
show the full list):
<script type="importmap">
{ "imports": { "multiplayer-canvas/torus": "/primitives/torus.js" } }
</script>The primitives import each other by relative path, so serving the folder whole is enough.
grid and torus
A cell (PX, 4px) and a module (M, 48px). A camera on a world that wraps
at both edges, with a zoom ladder that always lands on whole device pixels.
import { PX, M } from 'multiplayer-canvas/grid'
import { wrapPos, nearestWrap, snapZoom } from 'multiplayer-canvas/torus'
console.log(PX, M) // 4 48
// a 240px tile at x=4900 in a 5000px world, camera at 0, 1200px view:
console.log(wrapPos(4900, 0, 5000, 1200, 240)) // -100: it shows at the left edge
console.log(nearestWrap(4900, 100, 5000)) // -100: the copy closest to x=100
console.log(snapZoom(0.37, 2)) // 0.375: a zoom that is crisp at 2xpixel-type
Two bitmap faces (display, label) as data. A line comes out as one crisp
SVG path, or as a grid of cells you can play with.
import { pixelText, wrapText, gridOf } from 'multiplayer-canvas/pixel-type'
const { svg, w, h } = pixelText('HELLO', 'display', { scale: 2 })
console.log(w, h) // 336 96
console.log(svg.startsWith('<svg')) // true: put it in innerHTML
console.log(wrapText('a short sentence on two lines', 'label', 60))
console.log(gridOf('HI', 'display', 0).length) // rows of booleansdust
A cursor that crosses a pixel-type title knocks its cells loose, and they
settle back. Browser only. See examples/pixel-type.html.
import { pixelText, gridOf } from 'multiplayer-canvas/pixel-type'
import { createDust } from 'multiplayer-canvas/dust'
box.innerHTML = pixelText('HELLO', 'display', { scale: 2 }).svg
const dust = createDust()
dust.wire(box.querySelector('svg'), gridOf('HELLO', 'display', 0))
box.addEventListener('pointermove', (e) => dust.under(box, e.clientX, e.clientY))dither
Ordered (Bayer) dithering, one pixel at a time or as a matrix.
import { bayer, bayerLit } from 'multiplayer-canvas/dither'
for (let y = 0; y < 4; y++) {
let row = ''
for (let x = 0; x < 16; x++) row += bayerLit(x / 15, x, y) ? '#' : '.'
console.log(row) // a left-to-right ramp
}
console.log(bayer(8).length) // 8 rows of thresholdsslime
A slime mould in a round dish that grows toward the food you drop. The
simulation is pure. paintDish draws it on a 2D canvas, as in
examples/slime.html.
import { createSlime, step, feed, tones, DISH } from 'multiplayer-canvas/slime'
const mould = createSlime(42)
feed(mould, 20, 20)
for (let i = 0; i < 50; i++) step(mould)
const t = tones(mould) // one tone 0..3 per cell inside the dish
console.log(
DISH,
t.some((v) => v > 0)
) // 86 truemultiplayer-canvas/slime/server runs one shared mould on a server clock
and streams it to everyone over the presence room.
sky
A weather window: a square of pixel sky for the hour and the weather. It
returns RGBA ready for ImageData. The colours come from tokens.css. See
examples/sky.html.
import { frame, INKS, SIZE, STEP_MS } from 'multiplayer-canvas/sky'
const css = getComputedStyle(document.documentElement)
const inks = Object.fromEntries(
Object.entries(INKS).map(([k, v]) => [k, css.getPropertyValue(v).trim()])
)
let n = 0
setInterval(() => {
const rgba = frame({ cond: 'light rain', day: true }, Date.now(), n++, inks)
ctx.putImageData(new ImageData(rgba, SIZE, SIZE), 0, 0)
}, STEP_MS)multiplayer-canvas/sky/server holds one shared push, so anyone can move the
clouds for everyone.
presence
Live cursors. On the server, a room joins sockets and relays their moves. In
the browser, a client posts this tab's position and draws everyone else's.
They talk JSON over a WebSocket at /ws/cursors. examples/serve.mjs is a
complete server in about 40 lines (it uses ws).
Server:
import { createRoom } from 'multiplayer-canvas/presence/room'
import { parseFrame } from 'multiplayer-canvas/presence/protocol'
const room = createRoom()
wss.on('connection', (socket) => {
if (!room.join(socket)) return socket.close(1013, 'busy')
socket.on('message', (raw) => {
const f = parseFrame(String(raw))
if (f?.kind === 'move') room.move(socket, f)
})
socket.on('close', () => room.leave(socket))
})Browser (examples/presence.html):
import { initPresence } from 'multiplayer-canvas/presence'
import { wrapPos } from 'multiplayer-canvas/torus'
const p = initPresence({ scene: document.querySelector('#scene'), wrapPos })
addEventListener('pointermove', (e) => p.post(e.clientX / innerWidth, e.clientY / innerHeight))
const W = innerWidth,
H = innerHeight
const view = {
camX: 0,
camY: 0,
WORLD_W: W,
WORLD_H: H,
VW: W,
VH: H,
Z: 1,
edge: { top: 0, bottom: H },
}
requestAnimationFrame(function tick(t) {
p.frame(view, t)
requestAnimationFrame(tick)
})The protocol is pure and checks every inbound frame:
import { parseFrame } from 'multiplayer-canvas/presence/protocol'
console.log(parseFrame(JSON.stringify({ type: 'move', fx: 0.25, fy: 0.5 })).kind) // 'move'
console.log(parseFrame(JSON.stringify({ type: 'move', fx: 7, fy: 0 }))) // null: off the worldAlso in the presence folder:
presence/anchorplaces a cursor on a page of text, whatever the reader's width.presence/congaworks out who follows whom.presence/wireholds the constants both ends share.
rooms
Private rooms on the same socket. A room has a short code, an optional PIN, and idle and age limits. Its address carries the room.
import { startSession, findSession, checkSessionPin, endSession } from 'multiplayer-canvas/rooms'
import { withRoom, roomFromUrl } from 'multiplayer-canvas/rooms/url'
const rec = startSession({ kind: 'session', pin: true })
console.log(findSession(rec.code) === rec, checkSessionPin(rec, rec.pin)) // true 'ok'
const q = withRoom('?search=slime', { kind: 'session', code: rec.code })
console.log(roomFromUrl(q).code === rec.code) // true
endSession(rec.code)rooms/follow lets you follow somebody's cursor until you move your own.
rooms/view-sync shares what a page is showing, such as a video and its
time.
import { createFollow } from 'multiplayer-canvas/rooms/follow'
const follow = createFollow((id) => console.log('following', id))
follow.pick(3) // following 3
follow.input() // following null: your own input lets gomirror
On a wrapped world, a tile cut by the view's edge shows its other half at
the opposite edge. createMirror({ parent, observe }) keeps an inert, live
copy there. It needs no DOM globals, so you can test it with fakes.
pixel-moves
The motion vocabulary on a canvas overlay. zoomRects(from, to) marches
rectangles from one box to another. poof(el) breaks a thing into cells.
bigBang and implode fill and empty a surface. Each one calls done when
it ends. All of them honour prefers-reduced-motion.
import { poof } from 'multiplayer-canvas/pixel-moves'
button.addEventListener('click', () => poof(card, { done: () => card.remove() }))kit
tokens.css is every design token, in light and dark. kit.css is the
controls: buttons, fields, switches, tabs, the snap scrollbar, marquees and
tags. A few small scripts drive them. See examples/kit.html.
<link rel="stylesheet" href="/primitives/kit/canvas-tokens.css" />
<link rel="stylesheet" href="/primitives/kit/kit.css" />import { scrollMarkup, wireScroll } from 'multiplayer-canvas/kit/scroll'
import { armed } from 'multiplayer-canvas/kit/arm'
const titles = ['one', 'two', 'three']
pager.innerHTML = scrollMarkup({ titles })
wireScroll(pager.firstElementChild, { titles, onCommit: (i) => show(i) })
armed(deleteButton, async () => removeThing()) // the first press arms, the second actsimport { tagsMarkup } from 'multiplayer-canvas/kit/tags'
import { marqueeRun } from 'multiplayer-canvas/kit/marquee'
console.log(tagsMarkup(['notes', 'audio'])) // <ul class="k-tags" ...>
console.log(marqueeRun(300, 160)) // { d: 140, n: 35 }: walk 140px in 35 stepskit/caret puts a block caret at the insertion point of a .k-field.
Add content
Every entry is one markdown file in content/feed/. Frontmatter decides
where it goes.
- Article: no
type. It shows on/feed, in/feed.xmland at/feed/<slug>. - Case study:
type: case-study. It shows on/work, at/work/<slug>. - Experiment:
web_only: true. It is mounted at/<slug>and can bring its ownextra_cssandextra_js. Seecontent/feed/web-instruments.md. - Link:
external_url:and no body. The tile and the feed link straight out.
---
title: Hello, world
date: 2026-10-23
updated: 2026-10-23
slug: hello-world
tags: [notes]
summary: One line that says what this is.
---
Plain markdown.After you add or retitle an entry, run bash scripts/og/render.sh to draw
its share card (this needs Chrome). The test suite fails while a card is
missing. The about text is content/about.md.
Configure
site.config.js: the site's name, URL, description, author, profile links and the weather window's location. Most forks edit only this file..env: port, host, log level, proxy trust and store paths. Every key in.env.exampleis optional.BIBLE.md: the design contract.test/bible-sync.test.jskeeps it in step withprimitives/kit/canvas-tokens.cssandsrc/static/canvas/layout.js.
The owner signs in with a passkey at /login. On first boot the server
prints a one-time setup code; enter it at /login to enrol your passkey.
node scripts/auth/reset.mjs wipes the passkey and mints a new code. The
owner can open private rooms and see /admin.
Deploy
The Dockerfile builds the assets in one stage and runs the site as a
non-root user in a second. docker-compose.yml binds the site to
127.0.0.1:3000 and keeps data/ (passkeys, rooms, audit trail) on a
volume:
docker compose up -d --build
docker compose logs site # the passkey setup code is printed here on first bootPut a reverse proxy that terminates TLS in front of it, for example nginx:
server {
server_name example.com;
location / { proxy_pass http://127.0.0.1:3000; }
}The proxy must pass WebSocket upgrades on /ws/cursors through, which
needs the Upgrade and Connection headers forwarded (proxy_http_version 1.1;
and proxy_set_header Upgrade $http_upgrade;).
Possible next steps
- Publish the primitives to npm, so
npm install multiplayer-canvasworks without GitHub.
Credits
Built by Axel Stevens. Third-party
code, fonts and data are listed at /credits on a running site, and their
licences sit next to the vendored files.
License
MIT. See LICENSE.