# Tilework documentation (all pages) > Open source (AGPL-3.0), self-hosted 2D virtual office with pixel-art avatars, proximity audio and video, meeting rooms, screen sharing and chat. Built with Go, PixiJS and LiveKit to be cheap to host. Source: https://github.com/guhcostan/tilework --- URL: https://guhcostan.github.io/tilework/docs/ # Overview **Tilework** is an open source 2D virtual office in the spirit of the classic Gather experience. Your team walks around a pixel-art map, meets colleagues, talks by proximity, joins meeting rooms and shares screens, all in the browser. Tilework is an independent open source alternative to Gather, with no affiliation with the original product. It uses only original code and assets (or compatible licences). ## What makes it different: efficiency Most virtual offices are heavy. Tilework is designed around a budget: - **Server**: one Go process runs an authoritative world per office, with a spatial index and areas of interest, so a player is never compared against everyone else. - **Network**: movement is sent as small, batched, incremental updates at 10-15 Hz. Slow clients get the newest position instead of a growing backlog. - **Browser**: the whole static map is baked into one texture; only what the camera can see is drawn; hidden tabs stop rendering but keep calls alive; an economy mode caps the frame rate. - **Media**: audio and video go through a real SFU (LiveKit). Rooms are small and created on demand, and only authorised tracks are subscribed. > These are design goals. Capacity numbers are **targets to validate**, not proven results. See [Efficiency and benchmarks](https://guhcostan.github.io/tilework/docs/efficiency-and-benchmarks/) and [Status](https://guhcostan.github.io/tilework/docs/status/). ## How the pieces fit ~~~text Browser (React + PixiJS) | WebSocket (world, presence, chat) WebRTC (audio/video/screen) v v Go server -- issues scoped tokens / removes users --> LiveKit SFU | v SQLite (WAL): offices, users, memberships, sessions, maps ~~~ Three concerns are kept apart on purpose: 1. **World state, presence and movement**: in memory, in the Go server. 2. **Durable data**: SQLite. Movement is never persisted, only the last position when someone leaves. 3. **Audio, video and screen sharing**: the SFU. The app server only decides *who may join which room*. ## Where to go next - [Getting started](https://guhcostan.github.io/tilework/docs/getting-started/): run the whole stack locally. - [Architecture](https://guhcostan.github.io/tilework/docs/architecture/): how the server, client and SFU cooperate. - [Proximity and media](https://guhcostan.github.io/tilework/docs/proximity-and-media/): how conversations form and split. - [Privacy and security](https://guhcostan.github.io/tilework/docs/privacy-and-security/): consent, access control and current limitations. - [Status and roadmap](https://guhcostan.github.io/tilework/docs/status/): what is verified, what is not. --- URL: https://guhcostan.github.io/tilework/docs/getting-started/ # Getting started This guide runs the full development stack on one machine: LiveKit, the Go server and the Vite dev server. > The dev stack is **not** for production. It uses the well-known LiveKit development key pair and an unauthenticated join endpoint. Production mode refuses to start with those defaults (see [Privacy and security](https://guhcostan.github.io/tilework/docs/privacy-and-security/)). ## Requirements | Tool | Notes | | --- | --- | | Go | 1.26 or newer | | Node.js and pnpm | developed with Node 26.4 and pnpm 11.9; other recent versions are untested | | LiveKit server | `brew install livekit` on macOS, or the official release binary / image on Linux | | A Chromium-based browser | for the end-to-end tests: Google Chrome | ## Run it ~~~bash git clone https://github.com/guhcostan/tilework.git cd tilework ./scripts/dev.sh ~~~ The script starts three processes and writes their logs to `.run/`: | Service | URL | | --- | --- | | App (Vite) | http://127.0.0.1:5173 | | Go server | http://127.0.0.1:8080 (`/healthz`, `/readyz`, `/metrics`) | | LiveKit (dev mode) | ws://127.0.0.1:7880 | Open the app in **two different browsers or profiles** (a session is stored in a cookie), pick a name and avatar, walk toward each other, and enable audio and video when asked. ## Run the tests ~~~bash # Go: world simulation, collisions, access rules, proximity groups, backpressure cd server && go test -race ./... # Web: type-check and production build cd web && pnpm install && pnpm exec tsc --noEmit && pnpm exec vite build ~~~ The browser suite drives real Chrome instances (fake camera and microphone) against a **real LiveKit server**. It builds the app, starts its own server with a fresh database on port 18080 (and LiveKit in dev mode if nothing listens on 7880), and needs neither the dev stack nor any cleanup: ~~~bash cd e2e && pnpm install node run.mjs # all scenarios node run.mjs proximity rooms # some of them TILEWORK_EXTERNAL_URL=http://127.0.0.1:8080 node run.mjs # against a running stack, e.g. Docker Compose ~~~ Scenarios: proximity, consent, rooms, access, editor, social, security, resilience (see [Status](https://guhcostan.github.io/tilework/docs/status/) for what each proves). ## Run it with Docker ~~~bash docker compose -f deploy/docker-compose.local.yml up --build # http://localhost:8080 ~~~ That stack is for local evaluation only. The production stack, HTTPS, ports, backups and upgrades are described in [deploy/README.md](https://guhcostan.github.io/tilework/docs/self-hosting/). ## Configuration Everything is configured through environment variables. | Variable | Default | Purpose | | --- | --- | --- | | `TILEWORK_ENV` | `dev` | `dev` or `production` | | `TILEWORK_ADDR` | `:8080` | HTTP listen address | | `TILEWORK_DB` | `data/tilework.db` | SQLite file (WAL mode) | | `TILEWORK_STATIC_DIR` | empty | Directory with the built web app to serve | | `TILEWORK_ALLOWED_ORIGINS` | empty | WebSocket origin patterns; **required in production** | | `TILEWORK_OFFICE_SLUG` / `TILEWORK_OFFICE_NAME` | `default` | Single-office deployment identity | | `TILEWORK_SESSION_DAYS` | `30` | Session cookie lifetime | | `TILEWORK_TICK_HZ` | `15` | World tick rate, 5 to 30 | | `TILEWORK_AOI_CELLS` | `2` | Area-of-interest radius in 128 px cells | | `TILEWORK_MAX_PLAYERS` | `2000` | Hard cap per office | | `TILEWORK_MAX_GROUP` | `8` | Maximum people in a proximity group | | `TILEWORK_JOIN_RATE` | `20` | Join requests per second per IP (raise only for load tests) | | `TILEWORK_MEDIA_TOKEN_TTL_SECONDS` | `30` | Validity of a media join token | | `TILEWORK_MEDIA_RECONCILE_SECONDS` | `10` | How often SFU rooms are compared with the world membership | | `LIVEKIT_URL` | empty | Public `ws(s)://` URL browsers use; empty disables media | | `LIVEKIT_API_URL` | derived | `http(s)://` URL the server uses for admin calls | | `LIVEKIT_API_KEY` / `LIVEKIT_API_SECRET` | empty | LiveKit credentials | ## Endpoints and command line See the endpoint table in [WebSocket protocol](https://guhcostan.github.io/tilework/docs/protocol/#http-endpoints). The binary also has three one-shot commands: ~~~bash tilework -invite admin # print a new invite path (bootstrap the first production administrator) tilework -backup out.db # consistent online backup of the SQLite database tilework -healthcheck # exit 0 if /readyz answers (used by the container health check) ~~~ --- URL: https://guhcostan.github.io/tilework/docs/self-hosting/ # Self-hosting Tilework (single node) Two stacks, deliberately different: | | `docker-compose.local.yml` | `docker-compose.yml` (production) | |---|---|---| | `TILEWORK_ENV` | `dev` | `production` (refuses insecure config) | | LiveKit keys | public dev pair (`devkey`/`secret`) | yours, from `.env`; the server aborts on the dev key or a secret shorter than 32 chars | | TLS | none (`http://localhost` is a secure context) | Caddy, automatic certificates | | Joining | anyone reaching the port | invite links only | | Exposed | 127.0.0.1 only | 80/443, 7881/tcp, 7882/udp, 3478/udp | ## Local evaluation ```sh docker compose -f deploy/docker-compose.local.yml up --build # open http://localhost:8080 — the first person to join becomes the administrator ``` ## Production 1. Two DNS names pointing to the server: one for the app (`APP_DOMAIN`) and one for LiveKit (`LIVEKIT_DOMAIN`). 2. `cp deploy/.env.example deploy/.env` and fill it in (keys: `openssl rand -hex 8` / `openssl rand -hex 32`). 3. Open the firewall: **80/tcp, 443/tcp, 7881/tcp, 7882/udp, 3478/udp**. 4. Create the first administrator invite: `docker compose -f deploy/docker-compose.yml --env-file deploy/.env run --rm tilework -invite admin` prints `/?invite=…`; open it on your domain. Then invite others from *Settings → Invite people* (admins only). 5. `docker compose -f deploy/docker-compose.yml --env-file deploy/.env up -d --build`. The stack uses the prebuilt image `ghcr.io/guhcostan/tilework:latest` (linux/amd64 and linux/arm64, published by `.github/workflows/docker.yml`). Drop `--build` to pull it instead of building on the server; set `TILEWORK_IMAGE` in `.env` to pin a version. ### No domain yet? Any wildcard-IP DNS service works for a first try, for example `APP_DOMAIN=office.203-0-113-7.sslip.io` and `LIVEKIT_DOMAIN=rtc.203-0-113-7.sslip.io` (replace with your public IP, dots as dashes). Caddy gets real certificates for them. Use your own domain for anything that matters: these hostnames depend on a third-party resolver and share Let's Encrypt rate limits with everyone else using them. ## Public demo mode `DEMO=1` turns a production stack into a public sandbox ([decision 0011](https://guhcostan.github.io/tilework/docs/0011-public-demo/)): - anyone can join **without an invite, always as a member** (never as administrator, even as the first visitor); anonymous sign-ups are limited to about one every 5 s per IP, burst 5; - every `DEMO_RESET_HOURS` (default 6, on wall-clock boundaries) the office map is restored to the starter office and the office chat and all whiteboards are wiped; members and their sessions are kept; - the join screen and the top bar tell visitors it is a public demo and when it resets. Recommended with it: `MAX_PLAYERS=60`, `SESSION_DAYS=1`. The operator still creates an administrator with a CLI invite (step 4 above). Production guard rails stay on (HTTPS, real LiveKit keys, origin list). ## Oracle Cloud "Always Free" Scripts in [`deploy/oracle/`](https://github.com/guhcostan/tilework/tree/main/deploy/oracle) reproduce the public demo host: 1. `deploy/oracle/provision.sh` (needs the OCI CLI configured) creates a VCN, an internet gateway, a security list that opens exactly 22, 80, 443, 7881/tcp, 7882/udp and 3478/udp, a public subnet and an Ubuntu 24.04 VM, and prints its public IP. Default shape: Ampere A1 (2 OCPU, 12 GB). A1 capacity is often exhausted ("Out of host capacity"); `TILEWORK_OCI_SHAPE=VM.Standard.E2.1.Micro` uses the always-free AMD micro VM (1 GB RAM) instead. 2. `ssh ubuntu@IP "bash -s" < deploy/oracle/bootstrap-host.sh` opens the host firewall (Oracle's Ubuntu image rejects everything but SSH even when the security list allows it), adds 2 GB of swap and installs Docker with Compose. 3. Copy `deploy/` to the VM, write `deploy/.env` and start the stack as above. Nothing here is billed while you stay inside the Always Free limits. The micro VM's CPU is small: it is fine for a handful of people in calls, not for a large meeting (no capacity was measured on it). ### HTTPS, ICE, STUN/TURN and credentials - Browsers only allow camera, microphone and screen capture on HTTPS (or localhost), so TLS is not optional. Caddy obtains and renews certificates; ports 80/443 must be reachable for the ACME challenge. - **ICE/STUN:** LiveKit runs with `use_external_ip: true` and a single UDP port (7882) plus 7881/tcp as a fallback. The server discovers its public address with STUN, so it must be able to reach the internet. - **TURN:** LiveKit's embedded TURN listens on 3478/udp for clients that cannot use UDP directly. Networks that block all UDP fall back to ICE/TCP on 7881. **TURN over TLS on 443 is not configured** (it needs a certificate mounted into the LiveKit container); strict corporate firewalls may need it. - **Credentials are short-lived and scoped:** the app server mints a LiveKit join token per conversation (valid 30 seconds plus LiveKit's 60 s clock-skew tolerance, one room, one identity, publish sources limited to microphone/camera/screen share) and removes the participant from the SFU when they leave or lose permission. There are no long-lived shared media credentials in the browser. `LIVEKIT_API_SECRET` never leaves the server. - **Not verified in this repository:** the TURN relay path and behaviour behind strict NAT/firewalls. Test it (e.g. with a phone on mobile data and UDP blocked) before relying on it. ### Operations - Health: `/healthz` (liveness), `/readyz` (database reachable), `/metrics` (Prometheus text; **disabled unless `METRICS_TOKEN` is set** in `.env`, at least 16 characters; then scrape `tilework:8080/metrics` from inside the network with `Authorization: Bearer `; also blocked on the public hostname by the Caddyfile). Logs are structured JSON on stdout and contain no chat content and no tokens. - **Backup** (consistent while running): `docker compose … run --rm tilework -backup /data/backup-$(date +%F).db`, then copy it out of the volume (`docker compose … cp` or a bind mount). Store copies off the host. - **Restore:** stop the stack, replace `/data/tilework.db` in the volume with the backup (delete any `-wal`/`-shm` files next to it), start the stack. Everyone is signed out of nothing: sessions are in the database. - **Upgrade:** take a backup, `git pull`, `docker compose … up -d --build`. Schema migrations run automatically at start, inside a transaction, forward-only. To roll back the code, restore the backup taken before the upgrade. - **Update LiveKit:** the image is pinned to `v1.13.7`. Re-read the release notes and re-run the end-to-end suite (`e2e/`) before bumping. ## Upgrading to Tilework Tilework is the project's name as of 2026-10-02. Before replacing an existing installation, take an online backup with the currently installed binary and save your Compose configuration. Stop the old stack without `down -v`; never remove its volumes. - Rename application environment variables from `OG_` to `TILEWORK_`, including `OG_ENV` to `TILEWORK_ENV=production`, and remove the old variables. The new binary refuses non-empty old variables instead of silently using development defaults. LiveKit variables and the database schema are unchanged. - Use `tilework` for the binary, Compose service and project, and `ghcr.io/guhcostan/tilework` for the image. Update Git remotes to `https://github.com/guhcostan/tilework.git` and bookmarks to `https://guhcostan.github.io/tilework/`. - Preserve your actual volume names and database path. For the previous default production stack, add these overrides to `deploy/.env` (confirm the names first with `docker volume ls`; installations using a custom project name have different names). These overrides reuse office data and TLS state. Fresh installs use Tilework names throughout. ```env TILEWORK_DATA_VOLUME=opengather_og-data TILEWORK_DB=/data/opengather.db TILEWORK_CADDY_DATA_VOLUME=opengather_caddy-data TILEWORK_CADDY_CONFIG_VOLUME=opengather_caddy-config ``` - For the previous default local evaluation stack, set `TILEWORK_DATA_VOLUME=opengather-local_og-data` and `TILEWORK_DB=/data/opengather.db` in the shell (or an env file passed with `--env-file`) before starting the new local Compose file. Confirm the volume name first. Otherwise Compose creates an empty office. - Monitoring now reads `tilework_*` metrics. Session cookies are named `tilework_session`; browser preferences use `tilework.*`. Users need to rejoin with an invite and choose their local preferences again; stored office content remains in the reused database. Media capture still requires explicit consent. Historical benchmark files under `bench/results/` retain the names captured when they were measured. They are not measurements of the renamed release. The report reader accepts both historical and Tilework container names. --- URL: https://guhcostan.github.io/tilework/docs/faq/ # FAQ Short answers to the questions people ask first. Most answers link to the page with the details and the evidence behind them. ## What is Tilework? An open source, self-hosted 2D virtual office. Your team walks pixel-art avatars around a shared map and audio and video start when people get close, like bumping into someone in a hallway. It also has meeting rooms with access rules, private offices, screen sharing, chat, whiteboards, invites and a map editor. See the [overview](https://guhcostan.github.io/tilework/docs/). ## Is Tilework an open source alternative to Gather? It is an independent project inspired by the classic Gather (gather.town) experience: a 2D map with proximity video. It is not affiliated with or endorsed by Gather, and it uses none of its code, maps, sprites or branding. Sprites, tiles and the map are drawn by the project's own code, the code is AGPL-3.0, and every third-party dependency is listed in [THIRD_PARTY_LICENSES.md](https://github.com/guhcostan/tilework/blob/main/THIRD_PARTY_LICENSES.md). If you want a virtual office you can host and inspect yourself, that is exactly the use case. ## Is it free? Yes. The code is AGPL-3.0 and running it needs no paid service: one Go binary, SQLite and a self-hosted [LiveKit](https://livekit.io) media server. You only pay for the machine you run it on. The public demo runs on an Oracle Cloud Always Free VM with 1 GB of RAM. ## How do I try it without installing anything? Open the [public demo](https://office.152-67-49-137.sslip.io), pick a name and an avatar, and walk toward someone. Anyone can join; the map, office chat and whiteboards reset every 6 hours. To try proximity video alone, open it in two browser profiles. ## How do I self-host it? Use the Docker image `ghcr.io/guhcostan/tilework` (linux/amd64 and linux/arm64) with the production Docker Compose file, which adds Caddy for automatic HTTPS and LiveKit for media. You need two DNS names, a few open ports and an invite for the first administrator. The [self-hosting guide](https://guhcostan.github.io/tilework/docs/self-hosting/) walks through it, including a trick for trying it without owning a domain. ## How many people can one server handle? We do not claim a number yet. On the free Oracle micro VM that hosts the demo (1 GB of RAM), one run loaded over the internet from another machine kept 100 bots smooth, ran 200 with slower worst cases and saturated at 500 (all of them joined, nobody was kicked). For media, one 4-person video call and 20 people in audio-only groups were clean, while larger media runs lost packets, most likely because of the VM's capped CPU; the generator side was not measured. These are single runs on the smallest free machine, not capacity figures. Local benchmarks with the generator on the same laptop reached 1,000 bots without media and 40 people in calls. Details: [Benchmark results](https://guhcostan.github.io/tilework/docs/benchmark-results/) and [Status](https://guhcostan.github.io/tilework/docs/status/). ## What keeps it light? The server compares each person only with people nearby (a spatial grid and areas of interest), sends small batched updates at 10-15 Hz and sends movement as state changes instead of a position stream. The browser bakes the map into one texture, draws only what the camera sees and stops rendering hidden tabs. Media goes through an SFU in small rooms. See [Architecture](https://guhcostan.github.io/tilework/docs/architecture/) and [Efficiency and benchmarks](https://guhcostan.github.io/tilework/docs/efficiency-and-benchmarks/). ## Is audio or video recorded? No. Audio, video and screen shares only flow through the media server and are never recorded. Direct and conversation chat messages live in memory only; the office chat keeps its last 500 messages. Camera and microphone stay off until you opt in, and busy, away or invisible people are never pulled into a call. See [Privacy and security](https://guhcostan.github.io/tilework/docs/privacy-and-security/). ## Do I need a domain and HTTPS? Browsers only allow the camera and microphone on HTTPS pages or on localhost, so a public install needs HTTPS. The production Compose file gets certificates automatically through Caddy. For a first try, a wildcard-IP DNS name such as sslip.io works; use your own domain for anything that matters. ## Which browsers and devices does it support? The automated suite runs in Google Chrome, including an emulated phone with an on-screen movement pad. Other browsers and real phones are not covered by the tests yet, so reports are welcome. ## Can I change the office map? Yes. Administrators paint walls, place objects, draw meeting rooms with access rules (open, members, admins or a list) and assign desks and private offices from the built-in editor. Changes go live for everyone and persist. ## Is it ready for production? It is alpha software. The whole loop is tested in real browsers and runs on a public host, but TURN through UDP-blocked networks, long soaks and an independent security review have not been done. Read [Status](https://guhcostan.github.io/tilework/docs/status/) before relying on it. ## Will there be a hosted version? It is planned: a dedicated instance per customer, kept updated and backed up, in Team, Business and Enterprise plans with a number of seats each. The code stays AGPL-3.0 with every feature, so self-hosting remains free. Nothing is on sale yet; see the [Business model](https://guhcostan.github.io/tilework/docs/business-model/). ## How can I help? Try the demo and open an issue with what broke or felt wrong, star the repository on [GitHub](https://github.com/guhcostan/tilework) so others can find it, or contribute tests, measurements and fixes following [Contributing](https://guhcostan.github.io/tilework/docs/contributing/). --- URL: https://guhcostan.github.io/tilework/docs/architecture/ # Architecture ## Repository layout ~~~text server/ Go: HTTP + WebSocket, authoritative world, SQLite, LiveKit integration cmd/tilework/ entry point internal/world/ simulation, spatial grid, proximity groups, backpressure internal/gamemap/ map format, validation, collision grids, default office internal/media/ LiveKit access tokens and participant removal internal/store/ SQLite (WAL), migrations, sessions, invites, chat history, members and the admin audit log internal/httpapi/ routes, WebSocket session, limits, metrics, media reconciler internal/config/ environment configuration cmd/loadgen/ WebSocket load generator (benchmarks) web/ TypeScript + React + Vite + PixiJS client e2e/ real-browser end-to-end tests and asset generation bench/ benchmark scripts and raw results deploy/ Dockerfile, Compose files, Caddyfile site/ this landing page and documentation (GitHub Pages) docs/ documentation sources and architecture decision records ~~~ ## The world server Each office has one `World` that owns all state in a single goroutine (see [decision 0002](https://guhcostan.github.io/tilework/docs/0002-authoritative-world/)). - **Input**: clients send a direction (-1, 0 or 1 per axis), never a position. The server integrates movement at a fixed speed and tests collisions against the compiled map. - **Tick**: 15 Hz by default. Each tick advances moving players, evaluates proximity (about 4 Hz), and flushes one coalesced frame per client. - **State records, not position streams**: movement is deterministic, so a record is sent only when direction changes, reality diverges from what clients extrapolate, or as a 1 s resync ([decision 0006](https://guhcostan.github.io/tilework/docs/0006-state-change-records/)). - **Spatial index and area of interest**: a grid of 128 px cells. A player subscribes to the cells around it; entering or leaving that window produces enter/leave events instead of resending everything. - **Backpressure**: each client has a bounded outbound queue. Stale positions are overwritten for slow clients, while reliable messages (chat, control) are preserved; a client that cannot drain reliable traffic is disconnected and resyncs on reconnect. - **Persistence**: nothing per step. The last position is written when a player leaves; maps, users, invites, sessions and the office chat history live in SQLite. Direct and conversation chat are never stored. - **Admin edits**: the map can be replaced at runtime by an administrator; the world validates, hot-swaps and saves it ([decision 0007](https://guhcostan.github.io/tilework/docs/0007-invites-roles-and-map-editing/)). ## The client - **React** owns the UI (panels, dialogs, chat). **PixiJS** owns the world and runs its own ticker, so movement and animation never pass through React renders. - The static map is baked into a **single texture** (one draw call for the background). Avatars are procedural pixel art cached per look. Only entities inside the camera are drawn. The look itself is described in [Art style](https://guhcostan.github.io/tilework/docs/art-style/). - **Prediction and extrapolation**: the local player moves immediately and reconciles with server acknowledgements (the whole error, applied to inputs still in flight too, and hidden by a fading visual offset); remote players keep walking along their last known direction with the same movement rule as the server, and corrections are blended over ~80 ms. Client and server walk in sub-steps of at most 4 px and stop at the point of contact with a wall, so they agree however time is split ([0017](https://guhcostan.github.io/tilework/docs/0017-movement-reconciliation-and-render-on-demand/)). - **Render on demand**: the PixiJS frame loop runs every display frame but draws only when something on screen changed (and at 4 FPS otherwise, as a safety net). - Internal resolution is capped on high-density displays, hidden tabs stop rendering (calls continue), and an **economy mode** limits the frame rate to 30 and lowers resolution. ## Media The app server never touches RTP. It decides **who may join which room** and issues a short-lived, room-scoped token. Browsers connect straight to the SFU. See [Proximity and media](https://guhcostan.github.io/tilework/docs/proximity-and-media/). ## Future separation The boundaries are drawn so that later you can: - move LiveKit to its own host: the only contract is "issue a token for room X" and "remove participant from room X"; - run several app instances, each owning a subset of offices: a world has no shared mutable state with other worlds. None of this is implemented yet; it is just kept possible. The app server also runs a **reconciler** that removes SFU participants the world does not consider members ([decision 0008](https://guhcostan.github.io/tilework/docs/0008-token-lifetime-and-reconciliation/)). ## Decisions The reasoning behind the main choices is recorded as decision records: [0001 stack](https://guhcostan.github.io/tilework/docs/0001-stack/), [0002 authoritative world](https://guhcostan.github.io/tilework/docs/0002-authoritative-world/), [0003 media rooms](https://guhcostan.github.io/tilework/docs/0003-media-rooms/), [0004 JSON protocol](https://guhcostan.github.io/tilework/docs/0004-json-protocol/), [0005 minimal LiveKit client](https://guhcostan.github.io/tilework/docs/0005-minimal-livekit-client/), [0006 state-change records](https://guhcostan.github.io/tilework/docs/0006-state-change-records/), [0007 invites, roles and map editing](https://guhcostan.github.io/tilework/docs/0007-invites-roles-and-map-editing/), [0008 token lifetime and reconciliation](https://guhcostan.github.io/tilework/docs/0008-token-lifetime-and-reconciliation/), [0009 administration and accessibility](https://guhcostan.github.io/tilework/docs/0009-administration-and-accessibility/). --- URL: https://guhcostan.github.io/tilework/docs/proximity-and-media/ # Proximity and media ## Consent first Nobody's camera or microphone is touched before they opt in. The **Enable audio and video** dialog previews camera and microphone, lets you choose devices (and the audio output where the browser supports it), and explains that media is only sent to people nearby or in the same room, and only while your status is *Available*. ## Who can be in a conversation A person is eligible only if **all** of these are true: 1. they gave consent; 2. their status is **Available** (busy, away and invisible never join automatically, and leave immediately if already in one); 3. their connection is live. ## How groups form There are two kinds of group, each mapped to one small SFU room (see [decision 0003](https://guhcostan.github.io/tilework/docs/0003-media-rooms/)): - **Proximity groups** form between free people outside meeting rooms, up to 8 people. - **Meeting rooms** correspond to map areas with an explicit access rule: open, members only, admins only, a list, or a private office (open while unassigned; then its owners, admins and visitors an owner lets in). | Rule | Default | | --- | --- | | Join a group: within 64 px of a member and 80 px of the centroid | held for 0.5 s | | Leave a group: beyond 96 px of the nearest member or 112 px of the centroid | held for 1.5 s | | Group size limit | 8 | | Enter/leave a meeting room area | 0.4 s / 0.8 s | Behaviours these rules produce, all covered by unit tests: - **No flapping at the edge**: the leave threshold is farther than the join threshold, and both require a dwell time. - **Standing between two conversations**: you stay in the one you are in; there is no direct hop, you must leave first. - **Chains of avatars**: a long line of people cannot merge into one call, because of the centroid limit and the size cap. - **Meeting rooms are isolated from the corridor**: someone standing outside the door is not pulled into the room's call. ## What the browser does - Connects to the room named by the server with a scoped token. - Publishes the microphone (and camera if enabled) with simulcast and DTX. Screen sharing is available inside a live conversation. - **Audio is always subscribed; video is limited** to a configurable number of tiles, ranked so active speakers come first. Audio-only mode never subscribes to camera video. - On leaving a conversation it disconnects and **stops all capture** (the microphone and camera are released). - If the SFU connection drops unexpectedly, it asks the world server for a fresh token and rejoins. ## Setting up LiveKit for real use For production, LiveKit needs: - **HTTPS/WSS** in front of the signalling port (browsers require a secure context for camera and microphone); - **ICE**: a UDP port range or a single UDP mux port, plus TCP fallback; - **STUN/TURN**: TURN (ideally over TLS on 443) for users behind restrictive networks; - **Credentials**: a non-default API key and a secret of at least 32 characters, shared only between LiveKit and the app server. A complete, tested production configuration is **not written yet** (see [Status](https://guhcostan.github.io/tilework/docs/status/)). Follow the official LiveKit deployment documentation and re-verify against the version you deploy. --- URL: https://guhcostan.github.io/tilework/docs/art-style/ # Art style Tilework looks like a 2000s handheld RPG: a top-down 3/4 view, 16 px tiles, chunky characters with big heads, outlined props with three-tone shading, and white dialog-window menus. The goal is that style, **not** any existing game's assets. > **Original work only.** Every tile, prop, sprite, colour and UI frame is drawn by code in this repository. We do not use, trace, extract or re-colour assets from Pokémon or any other Nintendo, Game Freak or third-party game, and the project has no affiliation with them. A visual *style* (proportions, palette feel, pixel conventions) can be studied; assets and characters cannot be copied. See also [Contributing](https://guhcostan.github.io/tilework/docs/contributing/). ## Rules of the look | Aspect | Rule | | --- | --- | | Tile size | 16 x 16 px; the map is baked into one texture | | Camera | integer zoom only (nearest-neighbour), reference view about 320 x 200 world px (20 x 12.5 tiles); a 240 x 160 handheld screen would show 15 x 10 | | Light | from the top-left: a light top/left edge, a darker bottom/right edge | | Outlines | every prop and character has a 1 px dark outline, never pure black; on characters it is a very dark version of the neighbouring colour, so hair, skin and clothes stay distinct | | Shading | three tones per material; highlights drift warm, shadows drift toward violet | | Walls | banded face: cream upper wall, a coloured stripe that matches the room's floor, a wooden wainscot and baseboard; a flat dark ceiling cap on thick walls; contact shadow on the floor below | | Floors | beveled pale plates with joint lines (reception), fine diagonal hatching on pale blue (desks), wide ochre planks with staggered joints (social area), dithered carpets with a dotted border band (meeting rooms); doorways get a doormat | | Characters | 16 x 24 px content in an 18 x 26 cell; a 12 x 11 head (over half the sprite height), a 5-row torso and short legs; low-set eyes with a light blush; four directions, idle plus two walking frames; "right" is the mirrored "left" | | UI | white message frames with a coloured ring and dark lines on both sides; cream panels with an orange header tab; blue message strips; pill buttons with hard shadows; a black triangle cursor; teal scanline background on the join screen; a lavender name plate when entering an area | | Font | Pixelify Sans (SIL Open Font License 1.1), bundled in `web/src/assets/fonts` with its licence | ## Where the code lives ~~~text web/src/game/art/pixel.ts helpers: hue-shifted shading, deterministic hash, outlined slabs and blobs web/src/game/art/tiles.ts floors, walls, doorways, furniture, rugs and the whole-map baker web/src/game/art/characters.ts character painter, walk frames, per-cell outline, sheet builder web/src/game/palette.ts skin, hair, shirt and trouser palettes web/src/game/avatars.ts turns a sheet into cached PixiJS textures web/src/game/mapArt.ts turns the baked canvas into a texture web/src/theme-gba.css the dialog-window UI skin (overrides styles.css) web/dev/art-preview.* server-free preview page and the export used by the website ~~~ The painters are plain canvas code with no PixiJS dependency, so they can be previewed and exported without running the game. ## Preview and iterate ~~~bash cd web && pnpm exec vite --port 5180 # open http://127.0.0.1:5180/dev/art-preview.html?s=8 (sprites at 8x, map at 4x) ~~~ To refresh the images used by the README and the website: ~~~bash cd e2e node export-art.mjs # sprite sheets and the baked map (needs only the Vite preview above) node art.mjs # cast, walking GIF and banner (needs Chrome and ffmpeg) # in-game shots (needs a game server that serves the built client): TILEWORK_APP=http://127.0.0.1:8091 TILEWORK_API=http://127.0.0.1:8091 node screens.mjs ~~~ `web/dev/default-map.json` is a copy of the starter map from the Go server (`gamemap.Default()`); refresh it if that map changes. ## Adding things - **A prop**: add its size to the dimensions table and a painter in `tiles.ts`, register the type in the Go map validator (`server/internal/gamemap`), and keep it solid or not in the Go table. - **A hairstyle**: add a case in each of the front, back and side branches of `characters.ts` and a label in `palette.ts`. The server keeps avatar fields within fixed ranges, so widen those too. - **A floor**: add an entry to the `FLOORS` table in `tiles.ts` and use its name in the map's areas. --- URL: https://guhcostan.github.io/tilework/docs/protocol/ # WebSocket protocol JSON text frames, short field names (see [decision 0004](https://guhcostan.github.io/tilework/docs/0004-json-protocol/)). The connection is authenticated by the session cookie. The server closes the socket with a code that tells the client what to do ([close codes](#websocket-close-codes)); **1009** means a frame was too large. Every connection starts with a full `hello`; the same happens on every reconnect, so the client never needs to merge state across connections. ## Client to server | Message | Fields | Meaning | | --- | --- | --- | | `in` | `s` seq, `x`, `y` (-1, 0, 1), `b` run | movement intent; sent on change and every 400 ms while held. `b: true` runs at `cfg.run` times the walking speed | | `go` | `x`, `y` tile, or `id` of a player | run there along a path the server finds (walls, locked and forbidden rooms respected, portals avoided); any movement key cancels it | | `st` | `v` | set status: `available`, `busy`, `away`, `invisible` | | `consent` | `b` | opt in or out of automatic conversations | | `chat` | `sc` (`o` office, `g` group, `d` direct), `text`, `id` (peer for `d`) | send a message | | `loc` | `id` | ask where a person is | | `sync` | none | ask for the current state of everything in view (sent when a hidden tab becomes visible) | | `tok` | none | request a fresh media token for the current conversation | | `emo` | `n` 1-8 | show a reaction above your avatar (700 ms server cooldown); 8 is a short dance (key Z) | | `hand` | `b` | raise or lower your hand (shown in the roster and over the avatar) | | `note` | `text` | set your status note, one line, at most 60 characters ("" clears it); kept in memory only | | `wave` | `id` | wave at somebody anywhere in the office (2 s cooldown); busy and away people are not disturbed | | `use` | `x`, `y` tile | interact with the object at that tile (note, site, image, whiteboard); the server checks distance and area access | | `fol` | `id` (0 stops) | follow that player; any movement key stops it | | `lead` | `id` | ask that player to follow you (they decide) | | `lock` | `b` | lock or unlock the meeting room you are standing in | | `knock` | `n` area index | knock on a locked room or an assigned private office you stand close to | | `kans` | `id`, `b` | admit or decline a knock (only from inside the room; in an assigned office only an owner or an admin) | | `wb` | drawing ops | whiteboard edits: `draw` (batched pen points or one text), `del`, `undo`, `clear` (admin), `close`; all bounded, see decision 0010 | | `ping` | `c` | round-trip probe | ## Server to client | Message | Fields | Meaning | | --- | --- | --- | | `hello` | `you`, `role`, `status`, `office`, `cfg`, `map`, `roster`, `chat` | initial state; `cfg` has speed, the run multiplier (`run`), tick, media availability, your position and the areas denied to you; `chat` is the persisted office chat | | `w` | `k` tick, `m` [[id, x, y, d]...], `l` [id...] | **state records** in your area of interest and entities that left it | | `a` | `s`, `x`, `y` | acknowledgement with the authoritative position at the time of input `s` | | `p` | `a` people added/updated, `d` ids removed | presence roster changes; a person is `{id, n, av, s, r}` plus `h: 1` (hand raised) and `m` (status note) when set | | `c` | `sc`, `f` from, `to`, `x` text, `ts` | chat message | | `conv` | `op` = `join` / `m` / `leave` | conversation lifecycle, including room name and token on `join` | | `map` | `map`, `deny`, optional `x`/`y` | the office map changed (admin edit); `x`/`y` present if you were moved | | `loc` | `id`, `ok`, `x`, `y`, `a` | answer to a locate request | | `e` | `id`, `v` 1-7 | somebody's reaction; rendered above their avatar | | `obj` | `k` note/embed/image, `l` label, `d` content | object content, only after a nearby authorised `use` (never in the map) | | `fol` | `id`, `n` | you are now following (or stopped, `id` 0) | | `wv` | `from`, `n` | somebody waved at you | | `wvr` | `id`, `st` ok / busy / away / offline | what happened to your wave | | `ac` | `c` [count per area] | visible people per map area, in map order; sent with `hello` and then at most every 2 s when a count changes | | `ann` | `n` sender name, `x` text | an administrator's announcement banner (one line, at most 280 characters, never stored) | | `go` | `ok`, `x`, `y` | a walk-to was accepted (the server now steers you, `self` carries the positions) or refused (`ok: false`: no path, nobody there); its end arrives as `fol` with `id` 0 | | `self` | `x`, `y`, `dx`, `dy`, `d`, `tp` | your authoritative position while following or walking to a place, once more where a guided walk stops, and after a portal (`tp`) | | `lreq` | `from`, `n` | somebody asks you to follow them | | `deny` | `d` denied areas, `lk` locked rooms | your blocked-area list (locks change it for everyone) | | `knk` | `id`, `n`, `a` | somebody knocks on the room you are in (`done` dismisses) | | `knr` | `st` wait/ok/no/empty, `a` | the answer to your knock | | `wb` | `op` state/draw/del/clear/closed | whiteboard state and live strokes; deletes carry owner+id | | `spot` | `op` on/off | office broadcast: speaker, room and token (subscribe-only unless you are the speaker) | | `pong` | `c` | reply to `ping` | ### State records (`w`) A record is **not** a position sample: it is the state to extrapolate from ([decision 0006](https://guhcostan.github.io/tilework/docs/0006-state-change-records/)). `d` packs the facing direction and the movement: ~~~text d = facing | (dx + 1) << 2 | (dy + 1) << 4 | run << 6 facing: 0 down, 1 left, 2 right, 3 up; dx, dy in -1..1 idle = dx = dy = 0 ~~~ Clients keep moving the entity with the same rules as the server (speed from `cfg.speed`, times `cfg.run` when the run bit is set, diagonals scaled by 1/sqrt(2), axis-separated collision against the static walls of the map, sub-steps of at most 4 px, and a blocked axis moves to the point of contact with the wall; see [decision 0017](https://guhcostan.github.io/tilework/docs/0017-movement-reconciliation-and-render-on-demand/)). The server sends a new record when the direction or the run state changes, when reality diverges from that extrapolation, as a 1 s resync, and when you start seeing the entity. The run bit is only set while moving. Avatars are `{sk, hs, hc, sh, pa, pt}` small integers; `pt` is the companion pet (0 none, 1-8). The server clamps unknown values to 0 and drops unknown fields. ## HTTP endpoints | Endpoint | Auth | Purpose | | --- | --- | --- | | `GET /healthz`, `GET /readyz` | none | liveness, readiness (database) | | `GET /metrics` | bearer `TILEWORK_METRICS_TOKEN`; open in dev; **disabled (404) in production without a token** | Prometheus text | | `POST /api/join` | invite (production) | create a member; `{name, avatar, invite}`; sets the session cookie | | `GET /api/me` | cookie | current session or `{"authenticated": false}` | | `PUT /api/profile` | cookie | change your name and avatar | | `POST /api/invites` | admin | mint an invite `{role, maxUses, hours}`; the secret is returned once | | `GET /api/admin/invites` | admin | list invites (id, role, uses, expiry, creator, status); never the secret | | `DELETE /api/admin/invites/{id}` | admin | revoke an invite | | `GET /api/admin/members` | admin | list members `{members: [{id, name, role, joinedAt}], you}` | | `PATCH /api/admin/members/{id}` | admin | change a role `{role: "admin"|"member"}`; the member is disconnected and reconnects with the new role. The last admin cannot be demoted (409) | | `DELETE /api/admin/members/{id}` | admin | remove a member: sessions deleted, socket closed, call ended. Not yourself, not the last admin (409) | | `GET /api/admin/audit` | admin | the latest 100 admin actions, newest first | | `POST /api/admin/announce` | admin | `{text}` (1-280 characters): banner to everybody online; one per 15 s per admin (burst 2); audited as `announce` without the text | | `PUT /api/map` | admin | validate, apply and save a new map | | `GET /api/admin/map` | admin | the saved map including hidden interactive-object content (the world map redacts it; editors must round-trip through this endpoint or they will erase object content) | | `GET /ws` | cookie | the world WebSocket | ## WebSocket close codes | Code | Meaning | Client behaviour | | --- | --- | --- | | 4001 | replaced by a newer connection of the same user (another tab) | stop, show "session opened in another tab" | | 4002 | the client could not keep up (reliable queue overflow) | reconnect with backoff | | 4003 | an admin removed the member or changed their role | ask `/api/me`: still a member -> reconnect at once; otherwise go to the join screen with a notice | | 1013 | server closing the connection for another reason | reconnect with backoff | The reason is recorded on the world goroutine right before the outbound channel closes, so the writer always sends the right code. ## Limits | Limit | Value | | --- | --- | | Incoming frame size | 2 KB | | Movement input rate | 40/s, burst 80 | | Control messages | 10/s, burst 30 | | Chat | 3/s, burst 8; 500 characters | | Outbound queue per client | 128 frames | | Reconnect grace before removal | 10 s | | Join / map / profile requests | rate limited per IP or user; bodies capped (4 KB, 512 KB, 4 KB) | | Admin API | 5 requests/s per admin, burst 20; bodies capped at 256 B (role change) | Sustained violations disconnect the client. Text is trimmed of control characters on the server. --- URL: https://guhcostan.github.io/tilework/docs/privacy-and-security/ # Privacy and security This page states what the current code does and, just as important, what it does **not** do. "Tested" means an automated check exists and was run; see [Status](https://guhcostan.github.io/tilework/docs/status/) for the counts. ## Implemented and tested - **Consent gate:** media capture only starts after an explicit opt-in and never for people whose status is not *Available*. Leaving a conversation stops local capture. (Browser test.) - **Server-side room access:** meeting rooms have access rules (open, members, admins, list, office). The world refuses to move a player into a room they cannot use and only issues a media token for the room the player is legitimately in. Bypassing the client with raw inputs does not help. (Browser and unit tests.) - **Scoped, short-lived media tokens:** one room, one identity, publish limited to microphone/camera/screen share, data channels off, **valid 30 seconds, plus a 60 s clock-skew tolerance applied by LiveKit** ([decision 0008](https://guhcostan.github.io/tilework/docs/0008-token-lifetime-and-reconciliation/)). (Unit and browser tests decode the token.) - **Revocation and reconciliation:** leaving a group removes the participant from the SFU at once (with retries), and a reconciler removes any SFU participant the server does not consider a member every 10 s. Replayed, edited and expired tokens were tried against a real LiveKit server. (Browser test.) - **Invites and roles:** hashed, use-limited, expiring invites; administrator-only invite and map endpoints; production joins require an invite. (Store, HTTP and browser tests.) - **Sessions:** opaque random tokens, only a SHA-256 hash is stored; the cookie is HttpOnly, SameSite=Lax and Secure outside dev mode. - **Input validation:** WebSocket frames are size- and rate-limited; avatars are re-serialised from validated fields; names are bounded; maps are validated and compiled before being accepted; request bodies are size-limited. - **Production guard rails:** `TILEWORK_ENV=production` refuses to start without a real LiveKit key and a secret of at least 32 characters, a `wss://` media URL and an explicit allowed-origins list. Verified with the built container image. - **Logs:** structured JSON. Chat text, tokens and secrets are not logged (invite creation logs who and how many uses, never the token). - **Interactive objects:** note/site/image content is validated at map save (notes bounded, only absolute https URLs without credentials) and stripped from the public map; it is sent only to a player standing next to the object and allowed in its area. Embedded sites and images load only after an explicit click, in a sandboxed iframe or referrerless image. - **Lockable rooms:** locks are in-memory door state; movement refuses outsiders (admins bypass, admitted visitors pass), so a locked call cannot be joined from outside and media isolation follows automatically. Knocks reach only people inside and expire. - **Spotlight:** one extra SFU room with a single speaker; the audience gets subscribe-only tokens scoped to that room, revoked on step-off or consent withdrawal; the room is covered by the reconciler. - **Whiteboards:** strokes are bounded and relayed to viewers only; deletes require stroke ownership (admins may delete any), clearing requires admin; boards persist per office in SQLite. ## What is stored | Data | Where | Retention | | --- | --- | --- | | Names, avatars, roles, last position | SQLite | until the database is deleted | | Sessions and invites | SQLite, **hashed** | until expiry (an admin can revoke an invite or remove a member, which deletes their sessions) | | Admin audit log (actor, action, target name, short detail; no message content) | SQLite | last 2,000 entries per office | | **Office chat** | SQLite | last 500 messages per office; 100 replayed on connect | | Direct and conversation chat | memory only, never stored | gone when delivered | | Audio, video, screen share | not recorded; only flows through the SFU | none | ## Not implemented or not verified - **TURN and ICE** behaviour behind restrictive firewalls and NATs. TURN over TLS on port 443 is not configured. Documented in [deploy/README.md](https://guhcostan.github.io/tilework/docs/self-hosting/), not tested. - **Up to one reconcile interval (10 s) of exposure** remains for a replayed, unexpired token. - **Abuse handling is minimal:** administrators can remove a member (their sessions die and their socket closes at once) and revoke invites, but there is no ban list (a removed person can rejoin with any valid invite), no temporary mute or kick, no reporting flow and no content moderation. Office chat lines of a removed member stay in the last 500 messages until pruned. - **The audit log is administrative only** (joins by invite, role changes, removals, invite creation and revocation, map edits). It stores who did what and when, never message content, and keeps the last 2,000 entries per office. It is not tamper-proof: anyone with database access can edit it. - **`/metrics`** requires a bearer token (`TILEWORK_METRICS_TOKEN`, at least 16 characters) when one is set, is open in dev without one, and is **disabled (404) in production without one**. The Caddyfile also hides it from the public hostname; keep both. - **No independent security review.** Do not run this as a multi-tenant or public service. ## Reporting a vulnerability Please open a private security advisory on the GitHub repository instead of a public issue. --- URL: https://guhcostan.github.io/tilework/docs/efficiency-and-benchmarks/ # Efficiency and benchmarks > **Read the caveats.** Numbers on this page and in [Benchmark results](https://guhcostan.github.io/tilework/docs/benchmark-results/) come from a developer laptop with the load generator on the same machine. They show *where the cost goes* and *that a change helped*; they are **not** a capacity claim for a 2 vCPU / 4 GB server. Anything not run is marked **not run**. ## Design techniques (implemented) | Area | Technique | | --- | --- | | Server CPU | one goroutine per office, no locks on the hot path; spatial grid so proximity checks only look at neighbouring cells; proximity evaluated at ~4 Hz, not per movement; each state record is formatted once and its bytes shared by every observer; per-client pending records in a slice with an open-addressing index instead of Go maps ([0017](https://guhcostan.github.io/tilework/docs/0017-movement-reconciliation-and-render-on-demand/)) | | Network | intent-only input; **state records instead of a position stream** ([0006](https://guhcostan.github.io/tilework/docs/0006-state-change-records/)); one batched frame per client per tick; integer pixel positions; no WebSocket compression; incremental enter/leave events; hand-encoded JSON without reflection | | Slow clients | bounded queues; newest state overwrites stale ones; reliable messages preserved; disconnect on reliable overflow | | Persistence | movement never written; last position on leave only; office chat is the only stored conversation data | | Browser CPU/GPU | static map baked to one texture; culling to the camera; texture reuse; capped internal resolution; economy mode at 30 FPS; rendering paused in hidden tabs; **render on demand**: a still office is drawn about 4 times a second instead of 60 | | Movement | client prediction with full reconciliation of the inputs in flight; one movement rule (4 px sub-steps, stop at the point of contact) shared by server, prediction and extrapolation, so they agree at walls and after a late tick | | Media | real SFU (no mesh); small rooms; audio always, video limited and ranked by active speaker; simulcast and DTX; audio-only mode; capture stops when you leave a conversation | ## Researched techniques What the literature and the tools' own documentation recommend for running many people on a small machine, and where Tilework stands. *Measured* means a number in this repository; *expected* is reasoning that still needs a run. | Technique | Source | State | | --- | --- | --- | | Client-side prediction with server reconciliation: keep the inputs not yet acknowledged and correct them too | [Gambetta](https://gabrielgambetta.com/client-side-prediction-server-reconciliation.html), [Valve: Source multiplayer networking](https://developer.valvesoftware.com/wiki/Source_Multiplayer_Networking) | **done** (0017): no step back under 0-120 ms of jitter in the `movement` scenario; it drew up to 4.1 px back before | | Deterministic movement shared by both ends, independent of the time step | [Gaffer On Games: state synchronization](https://gafferongames.com/post/state_synchronization/) | **done** (0017): server and client stop on the same pixel at walls; a late tick no longer loses distance or crosses a wall | | Send state changes, not a position stream; dead-reckon in between | Gaffer On Games, same article | **done** ([0006](https://guhcostan.github.io/tilework/docs/0006-state-change-records/)): about 5x less server output | | Encode once, fan out the same bytes; keep general-purpose hash maps off the per-tick path | [Go blog: Swiss tables](https://go.dev/blog/swisstable) (how Go maps cost) | **done**, measured: world tick 1.99 -> 0.52 ms (300 concentrated) and 1.12 -> 0.38 ms (300 spread) in `go test -bench Tick` on an M1 Pro | | Render on demand instead of a continuous 60 FPS loop | [PixiJS Application and ticker](https://pixijs.com/8.x/guides/components/application) | **done**, measured: renderer plus GPU process about 26 % -> 9 % of one core with nobody moving (`idle` scenario method, M1 Pro) | | No per-message WebSocket compression on a CPU-capped host | [RFC 7692](https://www.rfc-editor.org/rfc/rfc7692) | **kept off**: deflate costs CPU per message, and world frames are small | | SFU with simulcast, dynacast, adaptive stream and DTX | [LiveKit: advanced tracks](https://docs.livekit.io/home/client/tracks/advanced/) | **done** | | Lower camera publish cost (15-20 FPS, smaller top layer): the SFU's cost grows with the packets it forwards | [LiveKit benchmark](https://docs.livekit.io/home/self-hosting/benchmark/) | **next**: media is the limit measured on the free VM (12 people with video lost 27 %); expected fewer packets per camera roughly in proportion to frame rate. Measure with `bench/run-vm-media.sh` before and after | | Turn off redundant audio (RED) where loss is low | LiveKit, same pages | **candidate**: about doubles audio payload bytes for loss resilience; packet count is unchanged, so on a CPU-bound host the gain is expected to be small. Measure bytes and loss | | Binary world frames | ([0004](https://guhcostan.github.io/tilework/docs/0004-json-protocol/) chose JSON) | **candidate**: a record is about 16-20 bytes of JSON and would be about 7 binary; roughly halves presence egress; protocol change | | Cap the Go heap with `GOMEMLIMIT` on a 1 GB host shared with LiveKit and Caddy | [Go GC guide](https://tip.golang.org/doc/gc-guide) | **candidate** for operators: protects against memory spikes; GC CPU rises only near the limit. Not measured | | Lower the frame loop rate when nothing moves | | **candidate**: the loop itself still runs at the display rate (renderer process about 5 % of a core idle in the run above) | ## Targets (to validate on the reference environments) - Remote movement latency **p95 below 150 ms** in the documented network. - About **60 FPS** on the reference laptop and **at least 30 FPS** in economy mode. - No continuous, unexplained memory growth after warm-up. ## Reference environments (record with each run) - **Server**: 2 vCPU and 4 GB RAM. CPU model, region, OS, network limits and co-located services must be recorded. - **Client**: laptop with integrated GPU and 8 GB RAM. Model, browser and resolution must be recorded. - Load generators run **outside** the measured machine. ## Scenarios and what has been run | | Scenario | State | | --- | --- | --- | | A | 100, 300, 500, 1,000 connected, no media, spread out and concentrated | **run locally** (generator on the same host), see results | | B | 100 present, 20 of them in five calls of four | **run locally** for the 20 in calls (SFU side, real synthetic media); the other 80 are scenario A | | C | 100 spread across 25 calls of four | **attempted, invalid on one laptop**; 40 people (10 calls) measured cleanly, 60+ saturate the machine | | D | 20-person meeting with a cap on received videos and one screen share | **run locally** (cap of 6 videos, one 720p share): 0 % loss | | browser | FPS, frame time and CPU with 300 bots around | **run on an M1 Pro** (not the reference laptop) | | E | simultaneous join, mass reconnect, two-hour soak | join and 500-client mass reconnect **run locally**; a shorter soak was run instead of the two-hour test; the two-hour test is **not run** | WebSocket benchmarks do not prove video capacity. Media tests must publish and consume real or representative synthetic media. The end-to-end suite proves the media *path* works (real audio and video RTP, screen share, revocation) but says nothing about capacity. ## Tools | Tool | Purpose | | --- | --- | | `server/cmd/loadgen` + `scripts/bench.sh` | WebSocket bots that walk the map, propagation-latency probes, time series; starts a fresh server per run | | `bench/run-presence.sh` | the whole scenario A / E matrix | | `bench/report.py`, `bench/compare.py` | Markdown tables from the JSON results | | `bench/cost.py` | cost model with **no prices built in** | | `bench/mediagen` + `bench/run-media.sh` | synthetic but real Opus/VP8 publishers and subscribers through a LiveKit server, measuring received bitrate, loss, jitter, stalls and SFU CPU | | `e2e/` | functional browser suite (real Chrome, real LiveKit) | | `/metrics` | Prometheus text: players, moving, tick histogram and max, frames and bytes out, records sent, skipped flushes, kicked clients, groups, media tokens, revocations and reconciliations, Go runtime | | debug HUD (Settings) | FPS, frame time, round-trip time, world messages per second, entity counts | Method, caveats and commands: [bench/README.md](https://github.com/guhcostan/tilework/blob/main/bench/README.md). ## Cost model (formula, no invented prices) ~~~text monthly_cost = app_server + media_server + egress_gb * price_per_gb + turn + storage + backups cost_per_connected_user = monthly_cost / average_concurrent_users cost_per_user_in_call = media_related_cost / average_users_in_calls ~~~ Keep **peak bandwidth** (sizing the link) separate from **monthly volume** (billing), and state the hours of use and concurrency assumed. Prices must be verified at the time of writing, which is why `bench/cost.py` takes every price as an argument. No server-price, VPS or TURN cost has been verified in this repository. --- URL: https://guhcostan.github.io/tilework/docs/benchmark-results/ # Benchmark results > **Most numbers here are local reference numbers, not a capacity claim.** Unless a section says otherwise, the server and the load generator ran on the **same laptop**, over loopback. Those sections show where the cost goes and that an optimisation helped; they say nothing about a 2 vCPU / 4 GB server or a real network. The section *On a free cloud VM, over the internet* is the exception: there the generator ran on another machine. Everything not listed is **not run**. Generated by [bench/make_results_doc.py](https://github.com/guhcostan/tilework/blob/main/bench/make_results_doc.py) from the raw JSON in [bench/results/](https://github.com/guhcostan/tilework/tree/main/bench/results). Method and caveats: [bench/README.md](https://github.com/guhcostan/tilework/blob/main/bench/README.md). ## Environment ~~~text date: 2026-09-29T12:40:50Z os: Darwin 25.5.0 arm64 cpu: Apple M1 Pro (10 logical cores) ram: 16 GB go: go version go1.26.5 darwin/arm64 ulimit -n: 1048575 git: no commit ~~~ Server and load generator on one host (loopback), Google Chrome not involved. Media disabled (no LiveKit). Each run is a **single** 45 s window after an 8 s warm-up: run-to-run variance was not measured, so treat differences below ~20 % as noise. ## Method (scenario A and E) - Bots join over HTTP, connect a WebSocket and walk BFS paths between random targets, spending ~30 % of the time walking, like people who stop to talk. - *distributed*: targets anywhere on the 60x36-tile starter map (interest areas still cover most of it); *concentrated*: everybody inside the 17x13-tile reception, the worst case for fan-out. - Propagation latency: probe bots change direction and another bot in the same process stamps when the server's frame shows the change (one clock, includes the generator's own scheduling). - Tick percentiles come from a coarse histogram (upper bucket bound); mean and max are exact. ## Before and after "state-change records" Baseline = every moving player's position sent every tick to every observer ([decision 0006](https://guhcostan.github.io/tilework/docs/0006-state-change-records/)). *Records/s per client* is how many entity records each client received per second. | Run | Srv out KB/s (base -> now) | Tick mean ms | Tick max ms | Srv CPU avg % | Records/s per client | Move latency p50/p95 ms (now) | |---|---|---|---|---|---|---| | A-100-distributed | 260 -> 99 | 0.77 -> 0.55 | 6.9 -> 5.2 | 7.0 -> 6.5 | 154 -> 42 | 39.4/66.3 | | A-300-distributed | 2336 -> 695 | 3.57 -> 1.89 | 14.5 -> 11.7 | 22.3 -> 15.7 | 489 -> 120 | 38.3/66.1 | | A-500-distributed | 6614 -> 1832 | 4.82 -> 2.73 | 29.9 -> 11.7 | 28.9 -> 27.7 | 838 -> 201 | 32.0/63.8 | | A-100-concentrated | 582 -> 153 | 1.52 -> 0.70 | 8.4 -> 6.2 | 9.5 -> 8.0 | 389 -> 79 | 36.1/66.8 | | A-1000-distributed | 27127 -> 6457 | 16.11 -> 5.50 | 67.5 -> 18.7 | 71.7 -> 44.9 | 1731 -> 370 | 40.0/65.6 | | A-300-concentrated | 5185 -> 1100 | 3.62 -> 2.63 | 19.0 -> 16.2 | 20.3 -> 19.8 | 1152 -> 215 | 46.4/67.0 | | A-500-concentrated | 14586 -> 2962 | 7.55 -> 3.78 | 56.0 -> 11.4 | 36.4 -> 34.4 | 1947 -> 359 | 39.9/63.9 | | A-1000-concentrated | 58188 -> 11491 | 27.64 -> 6.66 | 142.9 -> 59.8 | 96.2 -> 53.9 | 3882 -> 714 | 29.6/67.3 | | A2-300-concentrated-grouping | 5209 -> 1157 | 6.71 -> 2.15 | 15.2 -> 11.3 | 30.1 -> 21.2 | 1157 -> 225 | 34.8/66.0 | | E-storm-500 | 6416 -> 1909 | 7.06 -> 2.62 | 16.4 -> 11.8 | 38.1 -> 27.0 | 810 -> 209 | 41.7/64.3 | ## Full table after the change | Run | Bots | Region | Joined/failed | Srv CPU avg/max % | Srv RSS MB | Tick mean ms | Tick p95 (<=) ms | Tick max ms | Srv out KB/s | KB/s per client | Msgs/s per client | Move latency p50/p95/p99 ms | Kicked/skipped | |---|---|---|---|---|---|---|---|---|---|---|---|---|---| | A-100-concentrated | 100 | concentrated | 100/0 | 8.0/11.2 | 35 | 0.70 | 2 | 6.2 | 153 | 1.5 | 16 | 36.1/66.8/72.1 | 0/0 | | A-100-distributed | 100 | distributed | 100/0 | 6.5/9.4 | 35 | 0.55 | 2 | 5.2 | 99 | 1.0 | 14 | 39.4/66.3/70.5 | 0/0 | | A-1000-concentrated | 1000 | concentrated | 1000/0 | 53.9/61.4 | 127 | 6.66 | 10 | 59.8 | 11491 | 11.5 | 16 | 29.6/67.3/68.6 | 0/0 | | A-1000-distributed | 1000 | distributed | 1000/0 | 44.9/60.6 | 127 | 5.50 | 10 | 18.7 | 6457 | 6.5 | 16 | 40.0/65.6/68.8 | 0/0 | | A-300-concentrated | 300 | concentrated | 300/0 | 19.8/26.8 | 54 | 2.63 | 10 | 16.2 | 1100 | 3.7 | 16 | 46.4/67.0/72.4 | 0/0 | | A-300-distributed | 300 | distributed | 300/0 | 15.7/32.2 | 55 | 1.89 | 10 | 11.7 | 695 | 2.3 | 16 | 38.3/66.1/71.6 | 0/0 | | A-500-concentrated | 500 | concentrated | 500/0 | 34.4/49.4 | 76 | 3.78 | 10 | 11.4 | 2962 | 5.9 | 16 | 39.9/63.9/68.2 | 0/0 | | A-500-distributed | 500 | distributed | 500/0 | 27.7/32.9 | 73 | 2.73 | 10 | 11.7 | 1832 | 3.7 | 16 | 32.0/63.8/68.0 | 0/0 | ## Browser under a crowd (real Chrome) One real Chrome page (headless, GPU through ANGLE (Apple, ANGLE Metal Renderer: Apple M1 Pro, Unspecified Version), viewport 1440x900) walking around the map for 30 s per mode, while 300 WebSocket bots walk around too. *Entities / visible* = avatars inside the server's area of interest / drawn on screen. Script: [e2e/scenarios/perf.mjs](https://github.com/guhcostan/tilework/blob/main/e2e/scenarios/perf.mjs); raw JSON in [bench/results/browser-perf/](https://github.com/guhcostan/tilework/tree/main/bench/results/browser-perf). | Mode | FPS | JS frame ms | Main thread busy % | Script % | JS heap MB | Entities / visible | |---|---|---|---|---|---|---| | normal | 59.9 (min 58) | 0.17 | 7.6 | 4.4 | 34.8 -> 19.1 | 151 / 35 | | economy | 29.6 (min 28) | 0.35 | 6 | 3.4 | 20.1 -> 15.8 | 174 / 35 | This is an Apple M1 Pro with a fast integrated GPU, **not** the reference laptop (integrated GPU, 8 GB), and a single run per mode. It shows the renderer is far from saturated at this crowd size (economy mode is capped at 30 FPS by design); it does not show what a weaker laptop would do. ## Reading the numbers - **Bandwidth was the quadratic cost.** 1,000 concentrated bots wrote 58 MB/s (about 465 Mbit/s) before and 11.5 MB/s (about 92 Mbit/s) after. On a hosting plan with an egress quota or a 100 Mbit/s port this is the number that matters, not CPU. - **Tick budget:** at 15 Hz a tick has 66.7 ms. Before the change the worst tick of the 1,000 concentrated run (142.9 ms) exceeded it; after, the worst tick was 59.8 ms (mean 6.7 ms). That is *inside* the budget but with a thin margin on this laptop; a slower CPU would not have it. - **Latency did not move:** median 30-46 ms, p95 about 64-67 ms, p99 about 68-82 ms on loopback. It is dominated by the 15 Hz flush (a state change waits for the next tick), so a network round trip adds on top. The 150 ms target has **not** been validated across a real network. - **Memory:** server RSS 127 MB with 1,000 connected bots (Go heap about 34 MB at the end of the run); no server-side growth was concluded from these short runs. - **Server CPU** (percent of one core, sampled with §ps§) was 45-54 % at 1,000 bots, but the generator was competing for the same cores, so this is an upper bound for the server and not a sizing figure. - **Mass reconnect (scenario E):** 500 clients dropped at once and reconnected (with up to 2 s of client-side jitter, as the web app does) in 2.0 s, 0 failures, 0 kicked clients. - **Proximity grouping cost:** 300 concentrated bots with media consent formed 64 groups; mean tick 2.15 ms, the same order as the same run without groups. ## Media through the SFU (scenarios B, C, D) Synthetic but **real** media: [bench/mediagen](https://github.com/guhcostan/tilework/tree/main/bench/mediagen) publishes an Opus track (~56 kbit/s on the wire) and a 640x360 VP8 track (~520 kbit/s) from encoded files, and every participant subscribes to the others through a local LiveKit server 1.13.7 and measures the RTP it actually receives (loss from sequence gaps, RFC 3550 jitter, stalls over 500 ms). Everything ran on the same M1 Pro laptop; **the SFU CPU is percent of one core** (100 = one core), sampled with `ps`, and the generator competes for the same CPU. Script: [bench/run-media.sh](https://github.com/guhcostan/tilework/blob/main/bench/run-media.sh). | Run | Connected | Subscribed audio / video / screen | Received Mbit/s (all receivers) | Loss % | Jitter p95 ms (audio / video) | Stalls > 500 ms | First media p95 ms | SFU CPU avg / max % of a core | SFU RSS MB | SFU out MB/s | |---|---|---|---|---|---|---|---|---|---|---| | B-5x4-video | 20/20 | 60 / 60 / 0 | 34.6 | 0.0 | 0.9 / 0.7 | 0 | 266 | 46 / 54 | 201 | 4.3 | | C-10x4-video | 40/40 | 120 / 120 / 0 | 68.8 | 0.0 | 0.9 / 1.0 | 0 | 303 | 123 / 138 | 311 | 8.6 | | C-15x4-video | 60/60 | 180 / 180 / 0 | 66.1 | 33.6 | 49.3 / 61.7 | 69 | 534 | 181 / 202 | 555 | 8.4 | | C-25x4-audioonly | 83/100 | 208 / 0 / 0 | 4.7 | 60.7 | 121.2 / -1.0 | 824 | 3530 | 134 / 200 | 581 | n/a | | D-20-cap6-screen | 20/20 | 380 / 120 / 19 | 107.1 | 0.0 | 3.0 / 4.1 | 0 | 298 | 152 / 161 | 309 | 13.3 | - **B** = 5 calls of 4 (20 of the 100 present are in calls; the other 80 cost the app server only, see scenario A). **C-10x4** and **C-15x4** are *scaled-down* versions of scenario C (40 and 60 people instead of 100); **C-25x4-audioonly** is 100 people with audio only. **D** = one meeting of 20, each receiving at most 6 camera videos, one 720p screen share. - **Valid results are the ones with 0 % loss:** B (20 people), C-10x4 (40 people) and D (20 people). The SFU used 46 %, 123 % and 152 % of a core while forwarding 35, 69 and 107 Mbit/s: **roughly 1.3-1.8 % of one M1 Pro core per Mbit/s forwarded**, in these three points. - **C-15x4 (60 people) already lost 34 % of the packets** and the audio-only run with 100 people lost 61 % and could not connect 17 participants. With the generator on the same laptop we cannot tell how much of that is the SFU and how much the 100+ WebRTC clients (and their DTLS/SRTP work) fighting for the same cores and the loopback socket buffers. **Scenario C at its full size (100 people in 25 calls of 4) was attempted and failed** (first attempt with video: only 48 of 100 connected, 74 % loss; raw JSON kept in [bench/results/media-invalid-full-c](https://github.com/guhcostan/tilework/tree/main/bench/results/media-invalid-full-c)). It needs a generator on another machine. - **ESTIMATE, not measured:** if one vCPU of the 2 vCPU reference server were 2-3x slower than an M1 Pro core (an assumption nobody has verified here), the same ratio would mean ~3-5 % of a vCPU per Mbit/s forwarded, i.e. roughly 40-65 Mbit/s (80-120 forwarded 500 kbit/s video streams) before the SFU alone uses both vCPUs. Treat this as a hypothesis to test on the real server. - Not measured: TURN-relayed media, simulcast layer switching under congestion (the test tracks are single-layer, the browser client publishes two layers), a long run, packet loss injected on the network. ## 10-minute soak (presence only) 300 bots for 600 s (300 joined, 0 failed, 0 disconnects, 0 kicked), server and generator on the same laptop, media disabled, sampled every 30 s (19 samples). Raw JSON: [bench/results/soak/](https://github.com/guhcostan/tilework/tree/main/bench/results/soak). | | first 6 samples | last 6 samples | |---|---|---| | Go heap MB (mean) | 19.5 | 18.2 | | RSS MB (mean) | 54.9 | 55.5 | | Goroutines | 910 | 910 | | Server CPU % of a core (mean) | 24.1 | 23.2 | Tick p50 / p95 / max: 5 / 10 / 16.5 ms; propagation latency p50 / p99: 38 / 72 ms. Goroutines and RSS stayed flat and the heap oscillated around a stable level, so **no leak showed up in ten minutes** on this workload. Ten minutes cannot show a slow leak (hours) and this is not the two-hour soak. ## On a free cloud VM, over the internet Measured on 2026-10-01 against the production Compose stack (Caddy with TLS, Tilework, LiveKit 1.13.7) on the public demo host: an **Oracle Cloud Always Free VM.Standard.E2.1.Micro** in São Paulo (2 vCPUs as seen by the guest, burstable, 1 GB RAM plus 2 GB swap). The generators ran on one laptop in São Paulo (Apple M1 Pro, home internet) and reached the VM over the public internet, so these numbers include TLS, the network and the shared, throttled CPU of a free VM. Every presence run used a fresh database (benchmark mode: [deploy/docker-compose.bench.yml](https://github.com/guhcostan/tilework/blob/main/deploy/docker-compose.bench.yml)); media rooms were outside the office, so the app's reconciler left them alone. One run per row, so variance is unknown, and the 50 and 100 bot runs were taken about five hours before the others, so part of the jump in steal between 100 and 200 bots may come from the host's neighbours rather than the load. The laptop's own CPU and upload were not measured. Details and the image of each run: [bench/results/vm-oracle-micro/README.md](https://github.com/guhcostan/tilework/blob/main/bench/results/vm-oracle-micro/README.md). *Host CPU* is the share of all vCPUs busy; *steal* is time the hypervisor gave to other tenants, which on this burstable shape is consistent with its CPU cap. Docker's per-container CPU inflates under steal (it went past 100 % of a vCPU), so the table only gives the app's and Caddy's share of the CPU the two used together. Tick percentiles are not shown: the histogram's last finite bucket is 50 ms and the 200+ runs had a p99 above it. Scripts: [bench/run-vm.sh](https://github.com/guhcostan/tilework/blob/main/bench/run-vm.sh), [bench/run-vm-media.sh](https://github.com/guhcostan/tilework/blob/main/bench/run-vm-media.sh), [bench/vm-sample.sh](https://github.com/guhcostan/tilework/blob/main/bench/vm-sample.sh), [bench/vm_report.py](https://github.com/guhcostan/tilework/blob/main/bench/vm_report.py); raw files in [bench/results/vm-oracle-micro/](https://github.com/guhcostan/tilework/tree/main/bench/results/vm-oracle-micro). | Run | Joined/failed | Host CPU avg/max % (all vCPUs) | Steal avg % | App / Caddy share of their CPU % | App mem max MB | Host mem available min MB | Tick mean ms | Tick max ms | VM egress Mbit/s | KB/s per client | Move latency p50/p95/p99 ms (internet) | Kicked | VM samples | |---|---|---|---|---|---|---|---|---|---|---|---|---|---| | vm-A-50-distributed | 50/0 | 5.5 / 6.9 | 5.7 | 54 / 46 | 13 | 429 | 0.19 | 51.2 | 0.61 | 0.51 | 59 / 131 / 158 | 0 | 14 | | vm-A-100-distributed | 100/0 | 9.6 / 11.7 | 8.5 | 46 / 54 | 17 | 420 | 0.52 | 81.5 | 1.67 | 0.80 | 61 / 143 / 187 | 0 | 13 | | vm-A-200-distributed | 200/0 | 17.0 / 18.4 | 36.1 | 43 / 57 | 28 | 396 | 3.14 | 179.5 | 4.77 | 1.38 | 88 / 230 / 284 | 0 | 12 | | vm-A-300-distributed | 300/0 | 21.8 / 23.2 | 71.7 | 61 / 39 | 39 | 349 | 10.83 | 316.7 | 7.97 | 1.98 | 205 / 334 / 349 | 0 | 11 | | vm-A-500-distributed | 500/0 | 22.6 / 23.1 | 76.0 | 64 / 36 | 60 | 303 | 98.99 | 595.5 | 17.82 | 3.28 | 293 / 346 / 352 | 0 | 11 | | Run | Connected | Subscribed audio / video / screen | Host CPU avg/max % (all vCPUs) | Steal avg % | SFU mem max MB | Host mem available min MB | VM in / out Mbit/s | Received by participants Mbit/s | Loss % | Jitter p95 ms (audio / video) | Stalls > 500 ms | VM samples | |---|---|---|---|---|---|---|---|---|---|---|---|---| | vm-M-1x4-video | 4/4 | 12 / 12 / 0 | 7.4 / 8.3 | 7.4 | 61 | 410 | 2.6 / 7.6 | 6.9 | 0.00 | 7.8 / 4.4 | 0 | 11 | | vm-M-3x4-video | 12/12 | 36 / 36 / 0 | 12.7 / 14.1 | 36.2 | 111 | 372 | 7.8 / 25.4 | 22.1 | 26.67 | 32.0 / 69.3 | 6 | 10 | | vm-M-5x4-audio | 20/20 | 60 / 0 / 0 | 12.2 / 13.7 | 25.6 | 163 | 311 | 1.8 / 4.9 | 3.4 | 0.50 | 20.5 / n/a | 0 | 10 | | vm-M-5x4-video | 20/20 | 60 / 60 / 0 | 15.7 / 16.9 | 63.1 | 174 | 313 | 14.7 / 31.0 | 24.2 | 62.55 | 61.9 / 143.8 | 3124 | 9 | | vm-M-15x4-audio | 59/60 | 174 / 0 / 0 | 16.3 / 17.6 | 63.7 | 239 | 270 | 4.7 / 8.1 | 4.8 | 50.95 | 85.6 / n/a | 5028 | 9 | What it shows: - **Presence (no media).** Every bot joined in every run and nobody was kicked. 100 bots was the last run where every tick percentile fit the histogram (p99 5 ms, max 82 ms, against a 66 ms tick period at 15 Hz). At 200 bots the mean tick was still 3.1 ms and the median move reached others in 88 ms, but p99 went above 50 ms, the worst tick took 180 ms and p95 latency rose to 230 ms. From 300 bots the busy share stopped growing at about 22 % while steal passed 70 %, consistent with the shape's CPU cap; at 500 the mean tick (99 ms) exceeded the tick period, so the server was saturated even though nothing failed. Caddy, terminating TLS for every WebSocket, used 36 to 57 % of the CPU the app and Caddy used together. - **Media.** One meeting of 4 people with video and 20 people in audio-only groups of 4 ran with no loss that matters (0 % and 0.5 %). Twelve people with video lost 27 %, twenty lost 63 %, and the 60-person audio-only run (59 connected) lost 51 % at only 4.8 Mbit/s. Steal above 60 % in the worst runs makes the VM's CPU cap the most likely limit, but the generator laptop's CPU and home upload were not measured, so the generator side is not ruled out. These runs say nothing about a VM with dedicated CPUs. - **Reading it (estimate, not a capacity claim).** On a free micro VM, about 100 people walking around stayed smooth and 200 still worked with slower worst cases; one small video call ran clean and three did not. Single runs, one region, one generator machine. ## Not run | Item | Why it matters | | --- | --- | | Scenario C at full size (100 people in 25 calls) | attempted, invalid on one laptop (see above); needs a separate generator machine | | Any run on the 2 vCPU / 4 GB reference server (dedicated CPUs) | capacity claims need it; the free micro VM above is CPU-throttled | | TURN-relayed media, restrictive networks | not exercised | | Two-hour soak | only a 10-minute soak was run (above); the two-hour run is **not run** | | Browser FPS, frame time and CPU on the **reference** laptop (integrated GPU, 8 GB) | measured only on an M1 Pro, see above | | Multiple repetitions per data point | variance unknown | ## Reproduce ~~~sh bench/environment.sh OUT_DIR=bench/results/mine bench/run-presence.sh # ~25 minutes bench/compare.py baseline-json-15hz mine ~~~ --- URL: https://guhcostan.github.io/tilework/docs/status/ # Status and roadmap Tilework is at **MVP / alpha** level. This page separates what has been run from what has not. Last updated on 2026-10-02. ## Verified (executed) Environment: macOS (Apple M1 Pro, arm64), Go 1.26.5, Google Chrome with fake camera and microphone, LiveKit 1.13.7. Real-browser scenarios run against a **real LiveKit server**. | Check | What it proves | | --- | --- | | Go tests (`server/`, also with `-race`): world rules, dead reckoning, map reload, chat history, store (sessions, invites with a 40-way race, chat pruning, backup, roles and the last-admin guard, member removal, invite revocation, audit trail), the admin HTTP API (403/401 for non-admins, role change, removal, invite lifecycle, /metrics policy), production configuration guard rails, eviction from the world, media tokens and reconciliation | rules and invariants hold; media revocation is retried | | Browser scenario `proximity` | movement sync, remote avatars tracked within a few pixels, a call forms ~1.6 s after approaching with **real audio and video RTP**, leaving revokes the SFU and stops capture; a per-person volume button turns the other person to 50 % and to muted for the listener only | | `consent` | nothing without opt-in; busy blocks automatic entry and leaves calls; invisible hides the avatar | | `rooms` | admin-only room enforced by the server against raw inputs; tokens are scoped to one room; screen share is received; leaving a private room revokes access | | `access` | only admins mint invites; invite use limits; production refuses insecure configuration; CLI invite and online backup | | `editor` | real UI clicks paint walls and create a room; others get it live; the server enforces it; it persists across a restart | | `social` | office/direct/conversation chat scopes and privacy; three messages typed into the real chat composer with both panels open render for sender and receiver and the app stays alive (regression: an effect returned `scrollIntoView()`'s Promise on Chrome 154 and blanked the UI), rate limit, 500-character cut, persisted office history, profile change, desk owner label | | `admin` | through the real UI: members list, promote/demote an online member (evicted, reconnects with the new role), the last admin cannot be demoted, invite create/list/revoke (secret never listed, revoked link returns 403), activity log, removing a member sends them to the join screen with a notice and kills their session | | `a11y` | axe-core (WCAG 2.1 A/AA + best-practice rules) reports zero violations on the join screen, people and chat panels, status menu, settings, administration (three tabs), consent dialog and the map editor; dialogs trap focus and close with Escape | | `security` | replayed token connects but is evicted by the reconciler within seconds; edited token rejected; expired token rejected only after LiveKit's 60 s clock-skew tolerance (found by this test, now documented) | | `resilience` | WebSocket drop keeps position, call and audio; flooding cannot speed a player up; oversized frames close the socket; session and position survive a server restart | | `features` | keys 1-7 show reactions on both avatars; X opens a nearby note whose content is absent from the public map; follow walks around walls to the leader and stops on input; portals teleport once per arrival; a locked room blocks outsiders and admits one visitor after a knock; edited notes and whiteboards persist across a restart | | `spotlight` | stepping on the pad with consent goes on air; a listener in a separate private call receives real audio, video and screen share RTP; audience tokens are subscribe-only and scoped; withdrawing consent or stepping off empties the SFU room | | `movement` | walking measures 72 px/s and running (R / Shift) 144 px/s in the browser; another browser sees the run bit and agrees on where the runner stops (< 1 px); double-clicking the map runs there along a server path; "Walk to" in the people panel crosses the office around walls in ~3 s; a member cannot be routed into an admin-only room; pets picked on the join screen are stored and drawn next to their owner for others; recorded frame by frame while the owner walks an L, the pet follows the owner's trail about 20 px behind (median), never more than 3 px off it (no cut corners), never faster than 1.35x a walker, and rests about 19 px behind; pets and change live through the profile; unknown pets are rejected | | `movement`: prediction under jitter and at walls (2026-10-02) | with every input delayed 0-120 ms at random in the page, a runner holding a key is never drawn stepping back (largest step back 0.00 px over 79 frames; the previous code drew up to 4.1 px back) and stops within 0.2 px of where another browser sees it; walking diagonally under the same jitter neither axis steps back; running into a one-tile wall stops touching it (y 243) for both browsers with no pull back afterwards (the previous code stopped at 243.75). Go tests: the server stops at the same contact point walking and running, a 600 ms late tick covers the whole distance (it covered 36 of 86 px), and a 900 ms late tick cannot cross a one-tile wall (it did); a leave and a return of the same person in one tick send only the last of the two. [Decision 0017](https://guhcostan.github.io/tilework/docs/0017-movement-reconciliation-and-render-on-demand/) | | `idle` (render on demand) | with two people standing still the page draws about 4 frames a second (the frame loop keeps 60 FPS for input); every frame where a walking person's sprite moved or changed picture was drawn (37 of 37); my own walk draws at the display rate; a status change made while everybody stands still reaches the screen | | `rooms` (screen share) | besides isolation: the receiving browser expands the shared screen over the map (360 -> 1240 px) and shrinks it with Escape; the presenter's avatar shows a screen badge that clears when sharing stops; the capture runs at 1920x1080; sharing starts from the real Share screen button. When it cannot start, the bar says why, checked with browser errors stubbed in the page: the system blocked capture, the capture could not start, a page or browser without screen capture (a touch phone tapping the button), or no call yet; closing the picker shows nothing, and no camera message flashes in between (before this, a closed picker showed "Allow the camera and microphone"). Over the internet on the demo (2026-10-01, Chrome, two people in Office 3, build be569e2, before these messages) clicking Share screen delivered the screen to the other person | | `presence` | a wave crosses the office (behind a wall, out of view) and "Walk to them" runs to the waver; busy people are not disturbed and the sender is told; H raises a hand others see over the avatar and in the roster, H again lowers it, and two quick presses (before the server confirms the first) leave it down; a note typed in the status menu appears in the other person's people panel; Z dances for people nearby; the minimap shows server head counts and clicking it runs there; M hides it; on an emulated phone (390x844, touch) the on-screen pad walks the avatar; ? opens the shortcuts; an administrator announcement reaches both people as a banner that can be dismissed, and the activity log records it without the text | | `offices` | six private offices behind a hall; an administrator assigns Office 2 in the editor and everybody sees "Office 2 · name"; the owner walks in and gets no lock button; a visitor is stopped by the server, sees a Knock button at the door, the owner gets the request and clicks Let in, the visitor walks in and both share the office's own call while somebody in the hall stays out; a free office is open to anyone and can be locked (passed 3 runs in a row after fixing knocks on assigned offices being dropped by housekeeping) | | Docker | image builds (24 MB, non-root); the local Compose stack passes `proximity`, `consent` and `rooms`; in the container, production mode returns 403 without an invite, issues a Secure cookie with one, and refuses insecure configuration | | Public demo on a real host (2026-09-30) | the production Compose file (Caddy + Tilework + LiveKit 1.13.7) runs on an Oracle Cloud Always Free AMD micro VM (1 GB RAM + 2 GB swap, São Paulo) with `DEMO=1`; Caddy obtained Let's Encrypt certificates; LiveKit discovered and validated the public IP. `e2e/demo-smoke.mjs` from a laptop in São Paulo over the public internet: invite-less join as member, proximity call live, **real audio and video RTP received, over UDP** (selected ICE pair udp, RTT 10 ms). Demo-mode unit tests: no invite needed, never admin, per-IP sign-up limit, reset restores map and wipes chat and boards. On 2026-10-01 the demo was updated to the published image of commit a6a1e3a and, from the same laptop over the internet, the smoke test passed again (7/7, UDP, RTT 11 ms) and the `movement` scenario passed 14/14 against it (`TILEWORK_SHARED_OFFICE=1 TILEWORK_EXTERNAL_URL=...`): running at 144 px/s, walk-to, pets. Later on 2026-10-01 the demo ran b638676: `movement` 18/18 over the internet (pet median 20 px behind, never off the trail) and the smoke test with three messages typed into the chat composer (rendered for both, no errors). Then be569e2: on start the demo detected the old 60x36 map and installed the starter office with six private offices; smoke 9/9 (offices present, UDP media, chat composer) and two visitors walked into a free office and shared its own call (4/4). Assigning an office on the demo needs an administrator and was verified only locally | GitHub Actions (`.github/workflows/ci.yml`) runs the Go tests with `-race`, the web typecheck/build and this whole browser suite on an Ubuntu runner (software-rendered Chrome, real LiveKit); the run for the latest commit is green. The runner is much slower than a laptop (10-16 FPS), so the test walker steers frame by frame inside the page. The 2026-10-01 gauntlet run passed the full local browser suite after fixes to editor and status-menu layering. Focused scenarios passed movement 18/18 and social 20/20, including real composer sends and small-screen control hit tests; 11 pure pet tests passed for trails, running, corrections, low frame rates and resting visibility. Static checks, Go race tests and the docs build also passed. The final review then exposed inaccessible expanded phone reactions, covered by two additional social checks. See [the gauntlet procedure](https://guhcostan.github.io/tilework/docs/gauntlet/). Brief overlap at corners or a 180-degree reversal and temporary hiding behind a moving owner remain cosmetic limitations; the measured minimum gap applies to the tested forward L route, not every reversal. Rerun `scripts/gauntlet.sh` to reproduce the whole local verification. ## Measured (local reference, not a capacity claim) Scenario A (no media) up to 1,000 bots and a 500-client reconnect storm, with the generator on the same laptop: see [Benchmark results](https://guhcostan.github.io/tilework/docs/benchmark-results/). Media through a real SFU (B, D and a scaled C, 0 % loss up to 40 people in calls, ~1.3-1.8 % of one M1 core per forwarded Mbit/s) is in the same document. Highlights: state-change records cut server output about 5x and brought the worst tick at 1,000 concentrated bots from 143 ms to 60 ms; a 500-client mass reconnect finished in 2.0 s with no failures. **On a real host, generator on another machine (2026-10-01).** The production stack on the free Oracle VM.Standard.E2.1.Micro (São Paulo), loaded over the internet from one laptop in São Paulo: presence runs of 50 to 500 bots all joined with nothing kicked. 100 bots kept every tick percentile low (p99 5 ms); at 200 the mean tick was still 3 ms and the median move reached others in 88 ms, but p99 went above 50 ms and the worst tick took 180 ms; from 300 steal passed 70 %, consistent with the shape's CPU cap, and at 500 the mean tick (99 ms) exceeded the 66 ms period. Media: 4 people with video and 20 in audio-only groups ran clean (0 % and 0.5 % loss); 12 with video lost 27 %, 20 with video 63 %, and the 60-person audio-only run (59 connected) 51 % at only 4.8 Mbit/s. The VM's CPU cap is the most likely limit, but the generator laptop's CPU and upload were not measured. Single runs; tables in [Benchmark results](https://guhcostan.github.io/tilework/docs/benchmark-results/#on-a-free-cloud-vm-over-the-internet). **Movement and rendering costs (2026-10-02, Apple M1 Pro, single machine).** `go test -bench Tick ./internal/world` with 300 players, a third of them changing direction every tick (far busier than people are): 1.99 -> 0.52 ms per tick concentrated in the reception and 1.12 -> 0.38 ms spread over the map, three runs each, same allocations; output bytes are unchanged. Chrome (ANGLE on Metal, 1440x900) with nobody moving, three 10 s windows per build: renderer process 10.2 -> 5.4 % and GPU process 15.4 -> 3.3 % of one core. Before = the previous commit, after = [decision 0017](https://guhcostan.github.io/tilework/docs/0017-movement-reconciliation-and-render-on-demand/). Neither number is a capacity claim; the free VM was not re-measured. ## Not run / not implemented - **Scenario C at its full size** (100 people in 25 calls). B (20 people), a scaled C (40 people) and D (20-person meeting) were run locally with real synthetic media; 60 people and above saturate a single laptop. On the free cloud VM media saturates much earlier (see above). TURN through restrictive networks is untested. - **The 2 vCPU / 4 GB reference server with dedicated CPUs.** Only the CPU-throttled free micro VM was measured with a separate generator. A two-hour soak (only a 10-minute, 300-bot presence soak was run: flat goroutines/RSS, see benchmark-results.md). - **Browser FPS/CPU on the reference laptop.** Measured only on an Apple M1 Pro: 59.9 FPS (normal) and 29.6 FPS (economy, capped) with 300 bots, see [Benchmark results](https://guhcostan.github.io/tilework/docs/benchmark-results/). - **The movement fix on the public demo.** The demo still runs the previous image; the fix was verified locally (with injected jitter), not over the internet on the demo host, and the VM benchmarks were not rerun with the faster tick. - **TURN relay on a public host through UDP-blocked networks.** Direct UDP media and the production Compose stack with Caddy/TLS were verified on the demo; relay behaviour was not. - **Moderation is minimal:** admins can remove members and revoke invites, but there is no ban list, temporary mute, reporting flow or content moderation; the audit log is not tamper-proof. - **Accessibility:** only the automated axe-core audit and keyboard checks were run. The game canvas is not operable with a screen reader and nobody has tested with NVDA, VoiceOver or JAWS. - **Cost numbers:** only the formula and `bench/cost.py` exist; no prices were verified. - Only English is shipped (the i18n dictionary mechanism exists for more locales). ## Roadmap What comes next, including features people asked for (breakout rooms, a stage, audio-only areas, a lounge) and what hosted plans need, is in the [Roadmap](https://guhcostan.github.io/tilework/docs/roadmap/). How the project plans to sustain itself: [Business model](https://guhcostan.github.io/tilework/docs/business-model/). The milestones of the first version: 1. Architecture, decisions, repository, local environment. **Done.** 2. First functional slice with real proximity audio/video. **Done.** 3. Invites, presence, chat, persistence. **Done** (see limitations above). 4. Rooms, screen sharing, personalisation, basic editor. **Done.** 5. Benchmarks and evidence-driven optimisation. **Started:** scenarios A, B, D and a scaled C run locally, and presence and media runs on the free Oracle VM with the generator on another machine; the reference server, TURN and repeated runs are pending. 6. Reproducible self-hosted install and documentation. **Started:** the production Compose stack runs the public demo on a free Oracle VM (scripts in `deploy/oracle/`); multi-arch image published to GHCR. Not tested: TURN relay for UDP-blocked networks (the relay port range is closed on the demo host), load on the demo host. Deferred on purpose: recording, AI transcription, native apps, marketplace, enterprise integrations and 3D worlds. ## Tilework rename On 2026-10-02 the project adopted **Tilework**, including its application, documentation, generated site, art, repository links, module, CLI and runtime namespace. Local validation ran Go vet and race tests, both TypeScript checks, the production client build, web lint and unit checks, the generated-site check and **224 checks across all 15 real-browser scenarios**. The final gauntlet record identifies the reviewed source tree in `.gauntlet/last-gates.log`. Existing deployment and browser-state changes are documented in [Upgrading to Tilework](https://guhcostan.github.io/tilework/docs/self-hosting/#upgrading-to-tilework). Container publication, the site deployment and a public demo media smoke check are separate release checks; none establishes capacity. Historical benchmark evidence is unchanged, and earlier recorded commands used the previous operational names. No new cloud load benchmark was run for this rename. --- URL: https://guhcostan.github.io/tilework/docs/roadmap/ # Roadmap What is planned, in rough order, and what each item has to prove before it counts as done. What already works and what was measured is in [Status](https://guhcostan.github.io/tilework/docs/status/); this page is the work that remains. Items are plans, not promises, and none of them is implemented unless it says so. Every item goes through [the gauntlet](https://guhcostan.github.io/tilework/docs/gauntlet/): written acceptance criteria, a failing check first, the whole suite green, and independent review. ## Order of work Decided on 2026-10-02, to serve the [business model](https://guhcostan.github.io/tilework/docs/business-model/)'s target of three paying customers by early April 2027: 1. **Breakout rooms**: an admin API and a script first, then a panel in the administration dialog. 2. **Audio-only areas** set by administrators. 3. **Hosting foundation**: provisioning, backups with a tested restore, measuring the Team and Business machines, billing through Paddle, and the legal documents (see [Needed before offering hosted plans](#needed-before-offering-hosted-plans)). 4. **Stage**, after the first paying customer. 5. **Lounge**, after the first paying customer. ## Requested by people trying it (October 2026) ### Breakout rooms for group dynamics **Today:** meeting rooms with access rules and their own calls, private offices, and server-side teleports (portals, walk-to). Nothing assigns people to rooms. **Plan:** an administrator chooses how many of the map's meeting rooms to use, then presses *Shuffle*. The server assigns everyone present at random, moves them into their room and starts a countdown everyone sees. *Bring everyone back* returns people to where they were, or to a gathering spot. - Rooms are the meeting rooms already on the map; no temporary areas are created. Asking for more rooms than the map has is refused with a clear error. - The number of rooms decides: everyone is spread evenly, so 23 people in 4 rooms gives groups of 6, 6, 6 and 5. A group size can be entered as a hint that suggests the number of rooms. - Options: who takes part (skip busy and away people, skip administrators), keep groups closed while the countdown runs, a short warning before the end. - Late arrivals join the smallest group. - The server decides and moves people (the client never sends positions); groups exist only in memory and every shuffle is written to the audit log (who started it and how many groups, never what was said). - First step: an admin API endpoint and a small script, so dynamics can run before the UI exists. Then a panel in the administration dialog. - Done when: a browser scenario shuffles N people into X rooms of size Y, each group hears only its own room (checked on the SFU), the countdown ends with everyone back, and the Go tests cover uneven numbers and late arrivals. ### A stage for presenting to everyone **Today:** the *spotlight* pad. One person standing on it with audio and video on broadcasts audio, video and screen share to the whole office; the audience gets receive-only access. Tested with real media; audience size is not measured. **Plan:** a stage area on the map with a podium and an audience zone. - Several speakers at once (a panel), and an administrator or host who invites people on stage and takes them off. - Audience reactions and a raised-hand queue the presenter can see; the host can bring someone from the queue up to speak. - Optional *quiet audience*: people in the audience zone are not pulled into proximity calls while a presentation runs. - Cost note: every viewer receives the speaker's stream, so outgoing bandwidth grows with the audience. A stage with N viewers needs a measured run before any audience size is advertised. - Target for the first version: up to 50 viewers. It is a target until that run is measured; larger audiences would need a bigger media server or a different delivery. - Done when: a browser scenario puts two speakers on stage, a viewer receives both with real RTP, a hand in the queue is brought up, and leaving the stage revokes publishing on the SFU. ### Administrator control of cameras: audio only where it fits **Today:** each person can choose *Audio only* and a maximum number of videos in their own settings. Administrators cannot set a policy. **Plan:** an administrator sets, for the whole office and per area (room, stage, social area): - *Video allowed* or *audio only*; - *Camera on when joining* or *camera off when joining*. Default for a new office: video allowed, camera off when joining a conversation. People turn it on when they want to. *Audio only* is enforced by the server: media tokens for that area allow the microphone and screen share but not the camera, so it is real isolation and not a hidden button. It also keeps calls light on bandwidth and CPU, which suits small servers. - Done when: in an audio-only area the camera button explains why it is off, a raw attempt to publish video is refused by the SFU (checked in a browser scenario like the token checks in `rooms`), and the policy survives a restart. ### A space to hang out, apart from the stage and the rooms **Today:** a social area with couches, tables and a coffee corner, where proximity conversations form as people walk up to each other. **Plan:** - A lounge separate from the stage and the meeting rooms in the starter office, with tables where sitting down joins that table's conversation (a small, open group, unlike a closed room). - *Random coffee*: people who opt in are paired with someone they have not talked to and both get a "walk to them" prompt. - Ambient touches that cost nothing on the network (art and animation drawn locally). - Done when: two people sitting at a table share a call that a third person at the next table does not hear, and random coffee only pairs people who opted in. ## Needed before offering hosted plans The [business model](https://guhcostan.github.io/tilework/docs/business-model/) sells hosted instances. The software is not ready for that yet: - **Provisioning:** one command creates an instance for a customer on a fresh VM in the chosen region (US East or the EU at launch; the Oracle scripts in `deploy/oracle/` are a start), with their domain and automatic HTTPS. - **Backups and restore:** scheduled, off-site, and a restore that has been tested. - **Upgrades:** rolling a new image to every instance, with a way back. - **Monitoring and alerts** per instance, and a public status page. - **Seats:** count registered members and enforce the plan's limit, plus the fair-use limit on people in calls at the same time. - **Billing through Paddle** (monthly and annual, plan changes, first-month refunds), and a customer console for invoices and seats. - **Data export and deletion** for customers who leave, and the documents a paying customer needs: terms, a privacy policy and a data processing agreement that meet the GDPR before the first EU customer. - **Contributions:** adopt the Developer Certificate of Origin (signed-off commits) in the contributing guide and check it in CI. - **Enterprise:** single sign-on (OIDC or SAML) and audit export. Enterprise integrations are out of scope until the enterprise plan is pursued. - **Measured sizing:** the Team target (25 seats on 2 dedicated vCPU and 8 GB) and the Business target (75 seats on 4 dedicated vCPU and 16 GB) measured with the generator on another machine (`bench/run-vm.sh`, `bench/run-vm-media.sh`) before any seat count is promised. ## Quality and evidence still missing - **TURN relay** through networks that block UDP. Not tested; the demo host does not open the relay port range. - **The 2 vCPU / 4 GB reference server with dedicated CPUs**, and full-size scenario C (100 people in 25 calls). Only a CPU-throttled free micro VM was measured with a separate generator. - **Repeated runs** of each benchmark, sampling the generator machine too; every published run is a single run. - **A two-hour soak.** Only a ten-minute one was run. - **Browser frame rate on a laptop with integrated graphics.** Measured only on an Apple M1 Pro. - **Other browsers and real phones:** the suite runs Chrome only (plus an emulated phone). - **Moderation:** ban, temporary mute and a report flow. Today administrators can remove members and revoke invites. - **Accessibility with real screen readers** (NVDA, VoiceOver, JAWS). Only automated checks and keyboard use were run; the map canvas is not usable with a screen reader. - **Translations:** only English ships. The first market is international, so translations wait for demand. - **An independent security review.** - **Real prices** for the cost model (`bench/cost.py` has the formula only). ## Known small issues - The pet can briefly overlap its owner on a sharp turn or a reversal, and hides behind the owner for about half a second while walking toward the camera. - A raised hand is lowered after a reconnect. - Clicking *Lock room* twice quickly sends two lock requests (harmless: the second does nothing). - On custom maps, a door framed by a one-tile wall stub gets no doormat. - Assigning an office is recorded in the activity log as a generic map change. - `bench/run-vm.sh` passes the benchmark invite as a command argument (a short-lived invite to a throwaway database). - The web bundle is over 900 kB after minification; splitting rarely used dialogs would speed up the first load. - CI: some GitHub Actions still target Node 20, and `ubuntu-latest` moves to Ubuntu 26 from 19 October 2026; the workflows need a check against both. - The `security` browser scenario is flaky on GitHub's runners: the replayed-token attack sometimes fails to set up its media connection ("could not establish pc connection") even after one retry. The same message also counts as "rejected" in the tampered- and expired-token checks, so those two checks could pass without the token being refused. They should require the SFU's authorization error instead. ## Out of scope for now Recording, AI transcription, native apps, a marketplace and 3D worlds. Enterprise integrations wait for the enterprise plan. --- URL: https://guhcostan.github.io/tilework/docs/business-model/ # Business model How the project plans to sustain itself. This is a plan, not an offer: no plan is on sale yet and no price is set. The decisions below were taken on 2026-10-02; anything that depends on a measurement is marked as a target. ## Open source first Everything stays open source under AGPL-3.0. Anyone can run their own office with the Docker image and the [self-hosting guide](https://guhcostan.github.io/tilework/docs/self-hosting/), with every feature, at no cost beyond their own server. Paying buys hosting and support, not features: there is no open core, and a hosted office never gets a feature the open source build lacks. ## Market International from the start: the product and docs are in English, prices are in US dollars and customers can be anywhere. The project is run from Brazil and sells abroad. ## Hosted plans For teams that do not want to run a server: a dedicated office on its own machine, kept updated, backed up and monitored. One installation serves one office, so a separate machine per customer keeps media and data apart without multi-tenant code. | Plan | For | Seats (target) | Planned machine | How to buy | | --- | --- | --- | --- | --- | | **Team** | small teams | up to 25 | 2 dedicated vCPU, 8 GB (Hetzner CCX13 class) | self-service | | **Business** | small companies | up to 75 | 4 dedicated vCPU, 16 GB (Hetzner CCX23 class) | self-service | | **Enterprise** | larger organisations, special needs | agreed per contract | sized per contract | contact sales | The seat counts are **targets**. They become promises only after each machine has been measured with the load generator on another machine (see the [roadmap](https://guhcostan.github.io/tilework/docs/roadmap/)); if the measurement does not support them, the numbers change. - **A seat** is a registered member of the office. Each plan also has a fair-use limit on people in calls at the same time, because media is what costs; that limit comes from the same measurement. - **Regions at launch:** US East (Ashburn) and the EU (Germany); the customer chooses. Singapore is added when someone asks for it. Before the first EU customer signs, the privacy policy and a data processing agreement must meet the GDPR. - **Enterprise** is where single sign-on, audit export, custom terms, data residency and a service-level agreement live. None of these is built yet. ## Pricing A flat monthly price per plan, covering up to its seats, billed monthly or annually; a year costs ten months. Prices are not set: they come from this formula once the machines are measured. ~~~text price >= (server + media bandwidth + backups + monitoring + support time + payment fees) x (1 + margin) ~~~ - *server*: the machine the plan's seats need, measured as described in [Benchmark results](https://guhcostan.github.io/tilework/docs/benchmark-results/); - *media bandwidth*: grows with the number of people in calls at the same time and with video, much more than with registered seats; - *support time*: hours per month the plan includes, at the cost of that time. `bench/cost.py` holds the server part of the formula and needs these prices filled in. Reference points, checked on 2026-10-02 (they are inputs and comparisons, not Tilework prices): | Item | Price | Source | | --- | --- | --- | | Gather 1.0 | US$15 per user per month, or US$12 billed annually; charged by the number of people who can be in the space at once | [Gather support](https://support.gather.town/articles/2904336860-start-and-manage-monthly-or-annual-gather-1-0-subscriptions) | | Hetzner CCX13 (2 dedicated vCPU, 8 GB) | €42.99 or US$50.49 per month in Germany and Finland; US$50.99 in the US | [Hetzner price list](https://docs.hetzner.com/general/infrastructure-and-availability/price-adjustment/), excluding VAT and the IPv4 address | | Hetzner CCX23 (4 dedicated vCPU, 16 GB) | €85.99 or US$101.49 per month in Germany and Finland; US$102.99 in the US | same | | Paddle | 5% + US$0.50 per transaction | [Paddle](https://www.paddle.com/paddle-101) | For scale: 25 people on Gather's annual price is about US$300 per month. ## Selling and support - **Payments:** Paddle, as merchant of record: it sells to the customer, collects and remits sales tax and VAT in each country and handles refunds and chargebacks. Stripe can be reconsidered if volume makes its lower fee worth handling taxes directly. - **Trial:** the [public demo](https://office.152-67-49-137.sslip.io) is the trial, and the first month of a paid plan can be refunded. - **Support:** Team and Business get email support during business hours (São Paulo time), with no formal service-level agreement. Enterprise gets an agreement with an availability commitment, negotiated per contract. ## Contributions and licensing Because nothing will be sold under another licence, outside contributions need no contributor licence agreement. They will be accepted under the Developer Certificate of Origin: each commit is signed off (`git commit -s`). Adopting it in the contributing guide and CI is on the roadmap. ## Name The hosted service uses the **Tilework** name. Before it is announced, check the domain and run trademark searches in the markets it is sold to (USPTO, EUIPO and INPI). ## Goal **Target:** three paying customers within six months, by early April 2027. The order of work that serves it is in the [roadmap](https://guhcostan.github.io/tilework/docs/roadmap/#order-of-work). ## Still open - The legal entity for selling software abroad from Brazil, and its taxes: decide with an accountant before the first sale. - The price of each plan, after the machines are measured. - The fair-use limit on simultaneous calls, from the same measurement. - The domain and the result of the trademark searches. --- URL: https://guhcostan.github.io/tilework/docs/0001-stack/ # 0001 · Initial stack - **Status:** accepted (2026-09-29) - **Context:** the product must be cheap to host, with low CPU, RAM, GPU and bandwidth use, and installable on a single node. ## Decision | Layer | Choice | Verified version | | --- | --- | --- | | UI | TypeScript, React, Vite | React 19.3.0 · Vite 8.3.1 · TypeScript 7.0.2 | | 2D world | PixiJS (WebGL), kept outside the React render cycle | 8.21.0 | | API and world | Go, WebSocket (`coder/websocket`) | Go 1.26.5 · websocket 1.8.15 | | Data | SQLite in WAL mode through `modernc.org/sqlite` (pure Go, no CGO) | 1.60.1 | | Media | Self-hosted LiveKit as the SFU | server 1.13.7 · `livekit-client` 2.22.3 | | Runtime | Docker Compose (single node) | — | Versions were looked up in the official registries (Go module proxy, npm and LiveKit releases) on 2026-09-29. ## Why the pure-Go SQLite driver It builds without CGO, which keeps static binaries and cross-compilation for the Docker image simple. The expected cost is performance below C SQLite. Because the database only holds durable data (never movement), the gap should stay off the hot path, but this has **not been measured yet**. ## Consequences - One Go binary serves the API, the WebSocket and the static files. - Scaling horizontally means splitting offices across instances (see [0003](https://guhcostan.github.io/tilework/docs/0003-media-rooms/)). - Swapping the SQLite driver later is a change local to the `store` package. --- URL: https://guhcostan.github.io/tilework/docs/0002-authoritative-world/ # 0002 · Authoritative world in a single goroutine - **Status:** accepted (2026-09-29) ## Context Positions, collisions, room permissions and conversation membership must be trustworthy. Clients cannot decide where they are. ## Decision - Each office has one `World` that owns all of its state in **a single goroutine**. Connections talk to it only through an event queue, so the hot path takes no locks. - The client sends **intent** (a direction), never a position. The server integrates movement using the configured speed and the map collisions. - The client predicts locally and the server sends acknowledgements with the authoritative position; small differences are smoothed, large ones are snapped. - Simulation and send rate: **15 Hz** by default (`TILEWORK_TICK_HZ`, 5 to 30). Events are never sent per frame. - A **spatial grid** (128 px cells) with **areas of interest**: each player only receives entities from nearby cells (radius of 2 cells by default). Entering or leaving the area produces incremental events. - Global presence (people list, status) travels in separate, summarised messages without positions. - Pending positions are **coalesced per client**: when a client's queue is more than half full, sending is deferred and the newest position overwrites the stale one. Reliable messages (chat, control) are never dropped; if their queue overflows, the connection is closed (the client reconnects and resyncs). - Movement is **never** written to the database. Only the last position is saved, when a player leaves. ## Consequences - A very large office is limited by one goroutine's core. The real limit depends on measurement (benchmarks pending). - State lives in memory, so restarting the process drops connections; clients reconnect and the server restores the map and last position from SQLite. --- URL: https://guhcostan.github.io/tilework/docs/0003-media-rooms/ # 0003 · How media rooms are split - **Status:** accepted (2026-09-29) ## Context Putting everyone in the office into one WebRTC room would expose each person to (and bill bandwidth for) dozens of tracks they should not see. A P2P mesh does not scale to groups. ## Decision There are two kinds of SFU room, both **small and created on demand**: 1. **Meeting room**: one per map area of kind `room`, with an explicit access rule (open, members only, admins only, or a list). Name: `.o.r.`. 2. **Proximity group**: an ephemeral room of at most 8 people (`TILEWORK_MAX_GROUP`). Name: `.o.g`. The `` part is a random id created once per database, so two installations can share one LiveKit server without touching each other's rooms (see [0008](https://guhcostan.github.io/tilework/docs/0008-token-lifetime-and-reconciliation/)). A person is in at most one group at a time. The world server decides the composition and only then issues an SFU token for that room. ### Formation rules (hysteresis) | Situation | Rule | Default | | --- | --- | --- | | Join | be within 64 px of a member **and** within 80 px of the group centroid, for | 0.5 s | | Leave | stay beyond 96 px of the nearest member **or** 112 px from the centroid, for | 1.5 s | | Switching groups | not possible: you must leave first, then join | — | | Chains of avatars | the centroid distance limit and the size cap stop a line of people from merging the whole office | max 8 | | Meeting room | enter/leave the area, with a dwell of | 0.4 s / 0.8 s | Only people who gave consent **and** have the "available" status take part. Becoming busy, away or invisible removes the person from the group immediately. ## Why this avoids a single office-wide room The SFU room is the boundary of who can hear and see whom. Since each group holds at most 8 people (or the occupancy of one meeting room), the number of tracks a person subscribes to is bounded by group size, not office size. ## Consequences - Distributing offices across instances and splitting out a media server later is natural: the only contract between the two is "issue a token for room X" and "remove participant from room X". - Revocation removes the participant from the SFU when they leave the group. Tokens cannot be revoked, so their lifetime was cut to 30 s and a reconciler evicts unauthorised participants, see [0008](https://guhcostan.github.io/tilework/docs/0008-token-lifetime-and-reconciliation/) and [Privacy and security](https://guhcostan.github.io/tilework/docs/privacy-and-security/). --- URL: https://guhcostan.github.io/tilework/docs/0004-json-protocol/ # 0004 · JSON WebSocket protocol - **Status:** accepted (2026-09-29), to be revisited with benchmarks ## Decision Text JSON messages with short field names. A binary format only enters if measurement shows a benefit. Optimisations already applied, all independent of the wire format: - client input is sent only when the direction changes (plus a resend every 400 ms while a key is held); - deltas are batched per client, one frame per tick; - positions are integers in pixels; - WebSocket compression is disabled (it costs CPU and memory per connection); - a 2 KB cap per incoming frame and per-connection rate limits. ## Consequences Debugging is easy (traffic is readable in the browser). The cost is bandwidth and serialisation CPU above a binary format; how much that matters is a question for the presence benchmark (scenario A). --- URL: https://guhcostan.github.io/tilework/docs/0005-minimal-livekit-client/ # 0005 · Token issuing and revocation without the server SDK - **Status:** accepted (2026-09-29) ## Decision The Go server issues access tokens (HS256 JWTs in the LiveKit token format) and calls the LiveKit admin API (`RoomService/RemoveParticipant`, HTTP/JSON) with its own small code instead of importing the full server SDK. ## Reason Keep the binary and dependency tree small and isolate everything that talks to the SFU in the `media` package. ## How this was validated By **end-to-end test**, not by reading the documentation line by line: LiveKit 1.13.7 accepted the tokens, browsers published and subscribed to real media, and the removal call emptied the room. Re-read the official token and API documentation before upgrading LiveKit. ## Issued tokens - scoped to **one room** and one identity; - `canPublish` limited to the microphone, camera and screen-share sources; - `canPublishData` off; - valid for 30 seconds (a connection window; LiveKit adds 60 s of clock-skew tolerance, see [0008](https://guhcostan.github.io/tilework/docs/0008-token-lifetime-and-reconciliation/)). ## Accepted risk If LiveKit changes its token format, the `media` package breaks. The smoke test (`e2e/smoke.mjs`) exercises exactly that path. --- URL: https://guhcostan.github.io/tilework/docs/0006-state-change-records/ # 0006 · Movement is sent as state changes, not as a stream of positions - **Status:** accepted (2026-09-29). Supersedes the "position of every moving player every tick" behaviour of the first slice. ## Context The first implementation sent the position of every moving player to every observer on every tick (15 Hz). The load generator (see [Benchmark results](https://guhcostan.github.io/tilework/docs/benchmark-results/)) showed the cost is quadratic: with 1,000 bots in one small area the server wrote 58 MB/s, the mean tick took 27.6 ms and the slowest tick 143 ms, which is over the 66.7 ms budget. ## Decision Movement is deterministic (direction and speed are constant until an input, a wall or a locked door changes them), so the server publishes a **state record** only when the state changes, and clients keep walking the entity with the same rules: 1. A record is ~[id, x, y, d]: rounded position plus a direction code (`d = facing | (dx+1)<<2 | (dy+1)<<4`). 2. The server keeps, per moving player, a *shadow* position that follows exactly what a client would extrapolate (static walls only, axis-separated collision; the sub-step rule is now the one in [0017](https://guhcostan.github.io/tilework/docs/0017-movement-reconciliation-and-render-on-demand/)). 3. A record is sent when the direction changes, when the real position diverges from the shadow by more than 1.5 px (for example a door that is locked for that player), and as a 1 s safety resync. 4. Anyone who starts seeing an entity (entering a cell of interest, reconnecting, or a hidden tab coming back and sending `sync`) receives its current state. 5. Clients blend corrections over ~80 ms and snap when they exceed 24 px. The spatial grid and interest areas still follow the real position on every tick, independent of records. ## Impact (measured, loopback, generator on the same host, single runs) | Run | Server output KB/s | Mean tick ms | Max tick ms | |---|---|---|---| | 1,000 concentrated, before -> after | 58,188 -> 11,491 | 27.6 -> 6.7 | 142.9 -> 59.8 | | 1,000 distributed | 27,127 -> 6,457 | 16.1 -> 5.5 | 67.5 -> 18.7 | | 500 concentrated | 14,586 -> 2,962 | 7.6 -> 3.8 | 56.0 -> 11.4 | Propagation latency did not change (median 30-46 ms, p95 about 65 ms): it is bounded by the 15 Hz flush, not by the number of records. A real Chrome check shows a remote avatar tracked within 1.7-2.5 px while walking and converging to the exact stop position. ## Consequences - **Performance:** bandwidth now depends on how often people change direction, not on how long they walk. The worst case (everybody zig-zagging inside one area) is still quadratic, only with a smaller constant. - **Maintenance:** the movement rule exists twice (Go shadow, TypeScript extrapolation) and must stay identical. `TestDeadReckoning...`, `TestDivergenceIsCorrected` and the "remote view tracks a walk" browser check guard it. - **Behaviour:** a player pushing against a door that is locked for them produces a correction each tick until they stop. Hidden tabs do not extrapolate and therefore ask for a fresh snapshot when shown again. - **Install:** none. --- URL: https://guhcostan.github.io/tilework/docs/0007-invites-roles-and-map-editing/ # 0007 · Invites, roles, profile and map editing - **Status:** accepted (2026-09-29) ## Context A deployment needs a way to admit people, tell administrators from members, let administrators shape the office, and keep all of it enforceable on the server. ## Decision - **One office per deployment** (`TILEWORK_OFFICE_SLUG`). Creating an office is installing the stack; multi-office hosting is deliberately left for later. - **Invites:** random 192-bit tokens, only the SHA-256 is stored, with a role (`member` or `admin`), a use limit (1-1000) and a validity (at most 90 days). Spending an invite is one atomic `UPDATE ... RETURNING`, so concurrent joins cannot exceed the limit (tested with 40 racing requests). In production `/api/join` requires an invite; in `dev` it also works without one and the first member becomes administrator. The first production administrator is created with `tilework -invite admin`. - **Roles come from the database session, never from the client.** Admin-only endpoints (`POST /api/invites`, `PUT /api/map`) check the role on the server; room access rules (`open`, `members`, `admins`, `list`) are enforced by the world when moving and by token issuing. - **Map editing:** an administrator replaces the whole map. The server validates and compiles it (bounds, types, limits, spawn not inside a wall), hot-swaps it into the running world and only then saves it. The map size cannot change at runtime. On swap, room calls are dissolved (area indexes may change) and players standing inside a new wall are moved to the spawn. - **Profile:** members change their own name and avatar (`PUT /api/profile`); the change reaches the roster live. - **Chat retention:** office-wide chat keeps the last 500 messages in SQLite and replays 100 on connect. Direct messages and conversation chat are never stored. ## Consequences - Install: production cannot be used until the first invite is minted, which is intentional. - Security: rate limits on join, map and profile endpoints; bodies are size-limited. - Invite listing/revocation, role changes and member removal were added later, see [0009](https://guhcostan.github.io/tilework/docs/0009-administration-and-accessibility/). - Known gaps: an edit that fails to save after being applied is reported to the administrator but not rolled back in memory. --- URL: https://guhcostan.github.io/tilework/docs/0008-token-lifetime-and-reconciliation/ # 0008 · Short media tokens and SFU reconciliation - **Status:** accepted (2026-09-29). Replaces the 5-minute token lifetime of [0003](https://guhcostan.github.io/tilework/docs/0003-media-rooms/) and closes the "token replay window" listed in earlier status notes. ## Context LiveKit tokens cannot be revoked. Removing a participant from a room does not stop them from connecting again with the same token until it expires, so a leaked or replayed token would give access after the person had left the group. ## Decision 1. Join tokens are valid for **30 seconds** by default (`TILEWORK_MEDIA_TOKEN_TTL_SECONDS`). LiveKit's JWT library adds **60 s of clock-skew leeway** (measured in the security test: a token 12 s past its expiry was still accepted), so the effective lifetime is up to 90 s. A token is only needed to *connect*; clients that lose the media connection ask the server for a new one (`tok`). 2. A **reconciler** runs every **10 seconds** (`TILEWORK_MEDIA_RECONCILE_SECONDS`). It lists the SFU rooms of the office, then asks the world which identities belong to which room, and removes everybody else. The SFU is listed first and the world snapshot taken second, so a person who joined legitimately in between is never removed by mistake. 3. Room names carry a per-installation random prefix (`.o.…`), and the reconciler only looks at rooms with its own prefix. Without this, two app servers sharing one LiveKit (for example a dev stack and a test run) would evict each other's participants. 4. Leaving a group still removes the participant immediately (with retries); the reconciler is the safety net. ## Impact - A replayed, unexpired token can connect, but is evicted within one reconcile interval. This is tested against a real LiveKit server (the test uses 20 s tokens and a 3 s interval to keep the run short). - Edited tokens are rejected by the SFU (signature), and tokens fail to connect once expiry plus the 60 s leeway has passed. - Cost: one `ListRooms` call plus one `ListParticipants` call per active room every 10 s; negligible next to media traffic, but proportional to the number of active rooms. ## Not solved Up to one reconcile interval of exposure remains for a replayed token, and the reconciler only compares identities: two connections with the same identity in one room are handled by the SFU (the newer replaces the older). --- URL: https://guhcostan.github.io/tilework/docs/0009-administration-and-accessibility/ # 0009 · Administration, audit trail, /metrics policy and accessibility checks - **Status:** accepted (2026-09-29) ## Context Decision [0007](https://guhcostan.github.io/tilework/docs/0007-invites-roles-and-map-editing/) let administrators mint invites and edit the map, but they could not see or revoke invites, change a role, remove a member, or find out who did what. `/metrics` was open to anyone who could reach the port, and accessibility had never been checked. ## Decision - **Members:** `GET/PATCH/DELETE /api/admin/members`. A role lives in the database and is read when the socket joins, so a role change **evicts** the member (close code 4003, the world removes the player and revokes any call) and the client reconnects at once with the new role. Removal deletes the membership and every session of that user in that office and evicts the socket; the user row stays so old chat lines and audit entries keep a name. The last administrator cannot be demoted or removed, and nobody can remove themselves (enforced in one transaction, tested with unit and browser tests). - **Invites:** `GET /api/admin/invites` lists id, role, uses, expiry, creator and a derived status (active, expired, used up, revoked); the secret is never returned again because only its hash is stored. `DELETE` revokes. - **Audit trail:** table `audit_log` (migration 005) with actor, action, target name and a short detail. It records joins by invite, role changes, removals, invite creation and revocation and map edits. It never stores message content, is pruned to 2,000 entries per office, and writing it can never block or fail the action (a failure is logged). - **Deterministic close codes:** the kick callback now runs on the world goroutine right before the outbound channel closes and only records a reason; the socket writer sends 4001, 4002 or 4003 accordingly. Before, a race between the writer's generic close and the asynchronous kick could hide the reason. - **`/metrics`:** bearer token `TILEWORK_METRICS_TOKEN` (at least 16 characters). Open in dev without one; disabled (404) in production without one. A loopback-only rule was rejected because a reverse proxy on the same host would make every request look local. - **Accessibility:** dialogs trap focus and close with Escape, controls have accessible names, tables have headers, the status menu is a plain disclosure list, the map carries a text label, and an **axe-core** scenario (`e2e/scenarios/a11y.mjs`, WCAG 2.1 A/AA plus axe best-practice rules) audits every screen and dialog in a real browser. axe-core is MPL-2.0 and used only by the tests. ## Consequences - **Performance:** none on the hot path. Admin calls are rate limited (5/s per admin) and touch SQLite only. - **Maintenance:** one more table and migration; new admin actions must call `audit` and, if they change a member's access, `evict`. - **Install:** production deployments should set `METRICS_TOKEN` if they scrape `/metrics`. - **Limits, stated plainly:** no ban list (a removed person can rejoin with any valid invite), no temporary mute or kick, no reporting flow. The audit log is not tamper-proof. The automated audit finds a subset of accessibility problems; the game canvas itself is not operable with a screen reader, and no one has tested with NVDA, VoiceOver or JAWS. --- URL: https://guhcostan.github.io/tilework/docs/0010-social-features/ # 0010 · Gather-style social features ## Context The MVP covered presence, proximity calls, rooms, chat and administration. The remaining gap to a usable virtual office was the social layer: reactions, things to interact with, walking together, lockable doors, broadcasting and shared drawing. ## Decision - Emotes are seven fixed protocol IDs (keys 1-7, reaction buttons), fanned out to the sender area of interest over the lossy path (dropped for congested clients, never a kick) with a 700 ms server cooldown. Invisible players cannot emote. - Interactive objects (note, embed, image, whiteboard, portal, spotlight) are map props. Their content is validated at map-save time (notes bounded, URLs must be absolute https without credentials) and stripped from the public map; it is delivered only to a player standing next to the object and allowed in its area. Embedded sites and images load only after an explicit click, in a sandboxed iframe or referrerless image. - Follow is server-driven: the follower client stops predicting and applies authoritative self positions at the 15 Hz tick. Paths are BFS over tiles the follower may stand on (portals excluded), recomputed at most every 400 ms, capped at 64 followers. Any movement key ends it; it ends when the leader leaves or turns invisible. - Portals fire on arrival only (tracked per player, reset on step-off) and never into solid tiles or rooms the player may not enter, including locked ones. - Lockable rooms are in-memory door state on top of access rules: anyone inside can lock, everyone inside at that moment is admitted, admins bypass, an empty room unlocks itself. Knocks reach only people inside, expire after 60 s, and answers admit one visitor at a time. Movement enforces it, so media isolation follows automatically. Lock and board-clear actions are written to the audit log. - Spotlight is one extra SFU room per office with a single speaker (first eligible player on a pad keeps it). The speaker leaves their conversation while on air; the audience gets subscribe-only tokens. Stepping off or withdrawing consent revokes everything, and the room is part of the reconciler snapshot. - Whiteboards belong to whiteboard props, bounded (1500 strokes, 2000 points per stroke, 50 viewers, 100 points per message). Strokes relay to viewers including an echo to the author so a rejected edit never diverges the canvases; deletes require ownership (admins may delete any); only admins clear. Boards persist debounced to SQLite and reload on start; shutdown flushes synchronously. ## Consequences - Performance: follow pathfinding is bounded BFS on the world goroutine; spotlight adds one SFU room; whiteboard traffic is batched (50 ms) and bounded per message. No per-frame sends were added. - Maintenance: new prop kinds need art, dimensions on both client and server, and editor support. - Limits, stated plainly: no ban list or temporary mute; the audit log is not tamper-proof; embedded sites can still refuse framing (the dialog says so); the canvas itself is not screen-reader operable. --- URL: https://guhcostan.github.io/tilework/docs/0011-public-demo/ # 0011 · Public demo mode - **Status:** accepted (2026-09-30) ## Context People should be able to try Tilework without asking for an invite, then self-host it from the repository or the Docker image. A public instance cannot keep production's invite-only rule, but it must not hand out administrator rights or keep growing user-written content forever. ## Decision - `TILEWORK_DEMO=1` (only meaningful with `TILEWORK_ENV=production`, whose guard rails stay on) lets `/api/join` succeed without an invite. Demo joins are **always** forced to the member role: the store's rule that the first member of an empty office becomes admin does not apply to them. Invites keep working, so the operator bootstraps an administrator with the CLI. - Anonymous demo sign-ups use their own per-IP limiter (0.2/s, burst 5), separate from `TILEWORK_JOIN_RATE`. - Every `TILEWORK_DEMO_RESET_HOURS` (default 6) on wall-clock boundaries, the server restores the starter map, deletes the office chat history and every whiteboard in one transaction, then applies the same to the running world (map hot-swap, open boards closed, chat history cleared). Members and sessions are kept; the reset is audited as `demo.reset`. - `/api/me` returns `demo: {resetHours, nextReset}` so the client can warn visitors on the join screen and in the top bar. ## Consequences - Performance: none on the hot path; one timer and one transaction per reset period. - Maintenance: new persisted, user-written content types must be added to `ResetDemoContent`. - Install: two new optional variables in `deploy/.env` (`DEMO`, `DEMO_RESET_HOURS`). - Limits: users and memberships accumulate between resets (sessions expire after `SESSION_DAYS`); there is no CAPTCHA, so a determined abuser can still create many accounts slowly; direct and conversation chat are never stored, so a reset does not need to touch them. --- URL: https://guhcostan.github.io/tilework/docs/0012-movement-pets-and-screens/ # 0012 · Getting around faster, companions and better screen sharing - **Status:** accepted (2026-09-30) ## Context Large offices are slow to cross at walking speed, and finding a colleague meant locating them and then walking by hand. Pets are a well-loved personal touch in virtual offices. Screen sharing worked but showed the screen only as a small tile at 720p, too small for code or documents. ## Decision - **Running.** The input message carries a run flag (`b`). The server moves a running player at `RunMul` (2x) the walking speed and publishes the run state as bit 6 of the state record, so observers extrapolate at the right speed with the same dead-reckoning rule. A run change while moving is published at once. Shift inverts an "always run" toggle (R or a HUD button), so both habits work. - **Walk to.** `go` with a tile (double-click the map) or a player id (people panel) reuses the follow machinery: one breadth-first search on the server over tiles the player may enter, portals avoided, then the server steers at run speed. The path is computed once for a tile; a closed door aborts with `go ok:false`. Any movement key cancels. Guided movement snaps the cross axis onto the lane when within one step, so faster steps cannot catch wall corners, and the final resting position is sent to the walker. - **Pets.** A pet is one more avatar field (`pt`, clamped by the server). It is cosmetic and **client-side only**: each client animates the pet of every visible avatar behind its owner, so pets cost zero bandwidth, zero server CPU and no protocol messages. Eight species are drawn by code in `web/src/game/art/pets.ts`. - **Screen sharing.** Screens are captured at up to 1080p/15 fps with simulcast layers at 360p/3 fps and 720p/5 fps; adaptive stream picks the layer from the rendered size, so the small dock tile stays cheap and only an expanded or full-screen view pulls 1080p. Each shared screen can be expanded over the map, put in full screen or in picture-in-picture, and the presenter's avatar shows a screen badge to the people in the call. ## Consequences - Performance: running doubles the distance per tick but not the message rate; walk-to adds one bounded search per request (bounded by `maxFollowers` concurrent guided walks). Pets add one sprite per visible avatar and no network traffic. A 1080p screen share costs more upstream for the presenter; receivers of small tiles get the low layers. - Maintenance: protocol tests cover the run bit, the shadow agreement while running, walk-to arrival, cancellation and refusals; the `movement` and `rooms` browser scenarios cover the UI paths. - Install: none. --- URL: https://guhcostan.github.io/tilework/docs/0013-presence/ # 0013 · Presence: waves, raised hands, status notes, minimap and touch controls - **Status:** accepted (2026-09-30) ## Context In a busy office you need to reach somebody who is not next to you without opening a chat, see at a glance where people gather, signal that you want to speak, and say what you are doing. People on phones and tablets had no way to move. ## Decision - **Wave** (`wave` -> `wv` / `wvr`): a ping to anyone online, anywhere. The receiver gets a card with "Walk to them" (a server-routed run, see decision 0012) and "Wave back". Busy (do not disturb) and away people are not disturbed and the sender is told why; invisible players can neither wave nor be waved at (it would reveal them). 2 s cooldown per sender. - **Raised hand** and **status note** are roster fields (`h`, `m`), so they reuse the existing roster delta: no new broadcast path. The note is one line of at most 60 characters, cleaned on the server, kept in memory only (the client re-sends its own note after reconnecting) and never logged. - **Dance** is emote 8: the same rate-limited, area-of-interest fan-out as the other reactions, animated on each client. - **Minimap**: the client redraws a thumbnail of the already baked map four times a second (not per frame, not through React state) with the people it already knows about (its area of interest) and **per-area head counts** from the server (`ac`). Counts are computed every 2 s and sent only when they change, so the minimap shows busy places across the office without an office-wide position feed. Clicking it runs there. - **Cues**: synthesised chimes for waves, knocks and direct messages (no audio assets), and opt-in desktop notifications that only fire while the tab is hidden and never include message text. - **Touch pad**: an on-screen direction pad and run toggle shown on coarse-pointer devices. - **Announcements**: administrators send a one-line banner to everybody online through `POST /api/admin/announce` (admin API: authenticated, rate limited, audited). The audit row records that an announcement was made, never its text, and the text is not stored. - **Per-person volume**: each call tile has a local volume control (100 %, 50 %, muted for me). It only changes playback on that device (LiveKit participant volume) and never affects what others hear or what the SFU forwards. - **Keyboard help**: `?` (or the topbar button) lists every shortcut. ## Consequences - Performance: head counts cost one pass over the players every 2 s and one short message per player per change; waves, hands and notes are rare control messages. The minimap reuses the baked map canvas. - Privacy: notes are presence, like names, and visible to the office; no message text leaves the page in notifications. - Maintenance: world tests cover waves (delivery, cooldown, busy, invisible), roster fields, note cleaning, head-count publication and the dance emote; the `presence` browser scenario covers every UI path including the touch pad on an emulated phone. --- URL: https://guhcostan.github.io/tilework/docs/0014-gauntlet-and-pet-following/ # 0014 · Evidence-based review and cosmetic pet trails - **Status:** accepted (2026-10-01) ## Context Pet checks accepted an animation that stayed too close and cut corners. Chat checks sent through the API, missing a React effect cleanup crash when the visible panel received messages. Review also exposed chat controls covered by overlays at smaller viewports. ## Decision Use a repo-local gauntlet skill, linked from AGENTS.md: write acceptance criteria, reproduce behaviour failures, implement, run automatic gates, and ask fresh-context read-only critics to inspect results. Stop with evidence or report remaining failures after five rounds. Self-review fallbacks are identified explicitly. The gate script records its tree fingerprint and skipped checks. Move cosmetic pet following into pure TypeScript logic. Keep a short owner trail, target a point 20 px along it, and adjust speed for network corrections. Large teleports reseed the trail. After a stationary owner hides the pet, the pet strolls toward an available side spot. WorldView owns animation and supplies the static collision query for choosing that spot; the pet adds no server or network state. Chat scrolling effects return nothing. Regression tests type through the actual composer with panels open and hit-test controls at desktop and phone sizes. Layering keeps the panel usable while preserving editor and status-menu access. ## Consequences - Performance: bounded trail storage per visible pet; no per-frame network traffic, persistence or React updates. No new capacity measurement was made. - Maintenance: pure pet tests cover corrections, running, low frame rates and resting visibility; real-browser scenarios cover chat and observer animation. Regex lint catches selected hook mistakes, not every possible misuse. - Install: no runtime dependency or server change. Node 24 runs the TypeScript tests; @types/node is development-only. Review prompts and scripts live in the repo, without globally installing skills. - Limits: depth ordering can briefly hide the pet while walking toward the camera; corner turns can produce brief shoulder overlap. The cosmetic pet does not run a full pathfinder. Public demo behaviour requires a separate deployed-revision check. --- URL: https://guhcostan.github.io/tilework/docs/0015-private-offices/ # 0015 · Private offices - **Status:** accepted (2026-10-01) ## Context People expect a place of their own in a virtual office: a small room with a door, where a call stays private and visitors knock. The starter map had open desks and shared meeting rooms only. ## Decision - **An office is a room with access mode `office`.** `access.users` lists its owners (at most 4, validated with the map). While the list is empty the office behaves like an open meeting room, including the lock button. Once assigned, the world lets in owners and admins; everybody else needs an owner's yes. No new message types: `knock`, `kans` and `deny` carry it, and an assigned office is always announced in the `lk` (closed) list so clients show the Knock button. - **Admissions reuse the lock state.** The first visitor an owner admits creates the office's in-memory lock state; like a locked room, it is forgotten when the office is empty. Owners never need admission, the lock control does nothing in an assigned office, and only an owner or an admin is asked and may answer. Pending knocks on assigned offices survive housekeeping (it dropped knocks on rooms without a lock record, which made answers intermittently ignored). - **Assignment is a map edit.** The editor's assign tool works on desks and offices; it writes `access.users` and names the office "Office N · Name", through the existing admin-only `PUT /api/map`, validation and audit. - **Starter map.** The default map grows from 60x36 to 60x46 tiles: a hall under the social area (door at x 20-21) and six free offices of 9x6 tiles (the last 8x6). Saved maps are not changed. The doormat decoration now requires a real door frame, so a two-tile corridor is not drawn as one long doormat. ## Consequences - Performance: no new messages or per-tick work beyond checking each pending knock's area during housekeeping (bounded by players and 64 areas). The map is 28 % larger; interest areas and tick cost do not depend on map size. No new measurement was made. - Maintenance: Go tests cover free, assigned, a delayed knock answer, guest restrictions, owner re-entry and validation; the `offices` browser scenario uses the editor, the Knock and Let in buttons and the shared call. - Install: none. Members cannot claim offices themselves; an administrator assigns them. --- URL: https://guhcostan.github.io/tilework/docs/0016-tilework-name/ # 0016: Tilework identity and runtime namespace Date: 2026-10-02. Status: accepted. ## Context The project needs its own stable name while remaining describable as an independent open source alternative to Gather. A rename must cover the shipped application, site, source modules, installation commands and operational identifiers. ## Decision Use **Tilework** as the brand and `tilework` as the repository, Go module, command and Docker service name. Application environment variables use `TILEWORK_`, Prometheus metrics use `tilework_`, the session cookie is `tilework_session`, browser preferences use `tilework.*`, and the test hook is `window.__tilework`. Gather appears only in comparisons and affiliation or asset disclaimers. Reject non-empty retired `OG_` variables at startup with a migration message. This prevents an old production environment from being mistaken for an unconfigured development environment. Existing databases retain their schema and data; production Compose exposes explicit volume and database overrides to reuse them and Caddy's TLS state. See [Upgrading to Tilework](https://guhcostan.github.io/tilework/docs/self-hosting/#upgrading-to-tilework). Preserve historical benchmark inputs byte-for-byte and read both container naming schemes in the report generator. A rebrand is not a new capacity measurement. ## Impact Performance: no changes to the tick, networking, rendering or media algorithms; the startup configuration scan runs once. Maintenance: one consistent namespace across code, scripts and documentation. Installation: existing deployments must rename variables, reuse their volumes and database path, update monitoring, and have users rejoin; fresh installations use Tilework defaults. --- URL: https://guhcostan.github.io/tilework/docs/0017-movement-reconciliation-and-render-on-demand/ # 0017: Shared movement rules, full reconciliation and render on demand Date: 2026-10-02. Status: accepted. ## Context Running on the public demo (the free Oracle micro VM) pulled the player back while the key was held. Three causes were found and reproduced: 1. **Different wall stops.** The server integrated one step per tick (1/15 s, about 9.6 px when running) and refused the whole step at a wall, so it stopped several pixels before the wall the client walked up to in per-frame steps. Every run into a wall ended with a pull back. 2. **Late ticks lost distance.** A tick later than 250 ms (a CPU-starved VM) was clamped to 250 ms, so the server fell behind the client, which then snapped back. The same clamped step (36 px) could cross a one-tile wall. 3. **Reconciliation overshot.** An acknowledgement moved the player by half the error but left the recorded positions of the inputs still in flight unchanged, so each later acknowledgement applied the same error again. Corrections moved the drawn avatar directly, so network jitter showed as steps back. The browser also redrew the scene 60 times a second when nothing moved, which in an office is most of the time. ## Decision - **One movement rule on both sides** (`world.move` on the server, `WorldView.moveBody` in the client and the dead-reckoning shadow): the distance is walked in sub-steps of at most 4 px, and a blocked axis moves to the point of contact with the tile edge. The result does not depend on how time was split into ticks or frames, so prediction, extrapolation and the server agree at walls. A late tick integrates up to 1 s. - **The client integrates its own movement up to the instant of every input** (direction, run and heartbeat), the same instant the server applies it, instead of up to the last frame. - **No lost acknowledgement**: the ack is the only channel that keeps the owner's prediction on the truth. The server records the latest input sequence and retries an ack skipped for congestion on later ticks; the client applies an ack newer than anything seen so far against the live position when its history entry already drained (a delayed message after a stall). Without both, a single lost ack left the owner diverged with no recovery. - **Full reconciliation**: an acknowledgement applies the whole error to the position and to every input still in flight. The avatar is drawn at the position plus a visual offset that absorbs the correction and fades (120 ms time constant, on each axis never faster than 60 % of the slowest per-axis walking speed, that of a diagonal walk), so a held key never draws a step back in any direction. Only corrections above 128 px skip blending (a real teleport resets the offset anyway). - **Render on demand**: PixiJS no longer renders on its own ticker. The frame loop (input, extrapolation, animation) still runs every display frame, but it draws only when the camera, a sprite's position or picture, or a pet changed, when a timed effect is on screen (emote, banner, walk-to marker, locate pulse), or when the session changed something (people, status, speaking, map). A 250 ms heartbeat draws anyway as a safety net. - **Cheaper world frames**: each client's pending records are a slice in queue order with an open-addressing index (no Go map on the hot path), and every entity formats its state record once per record and shares the bytes with all observers. ## Impact - Performance: on an Apple M1 Pro, `go test -bench Tick ./internal/world` (300 players, a third changing direction every tick) went from 1.99 to 0.52 ms per tick concentrated in the reception and from 1.12 to 0.38 ms spread over the map; bytes on the wire are unchanged. A Chrome page with nobody moving draws about 4 frames a second instead of 60; renderer plus GPU process CPU went from about 26 % to 9 % of one core in the same run. Local single-machine numbers, not a capacity claim. - Maintenance: `world.move` and `WorldView.moveBody` must change together (the Go tests and the `movement` scenario check that they stop at the same spot). Anything new that changes the picture outside sprites and the camera must set `dirty`; the `idle` scenario checks that changes are drawn. - Installation: none. The protocol is unchanged. --- URL: https://guhcostan.github.io/tilework/docs/contributing/ # Contributing Thanks for looking. The project is early, so the most useful contributions are careful bug reports, tests and measurements. ## Ground rules - **English everywhere**: code, comments, commit messages, documentation and issues. - **Original work only**: do not copy code, maps, sprites or branding from Gather or any proprietary product. Any third-party asset or dependency must have a compatible licence and be listed. - **License**: contributions are accepted under **AGPL-3.0**, with no additional restrictions on commercial use. - **Evidence over claims**: do not describe a number as "capacity" unless it was measured, with the environment recorded. Keep measured results, estimates and not-run tests clearly separated. ## Development loop Every change runs the [gauntlet loop](https://guhcostan.github.io/tilework/docs/gauntlet/): written acceptance criteria, a test seen red first, `scripts/gauntlet.sh` green, and independent critics (UX, invariants, regression) with evidence. ~~~bash ./scripts/dev.sh # full local stack cd server && go vet ./... && go test -race ./... # server tests cd web && pnpm exec tsc --noEmit # type-check the client cd e2e && pnpm install && node run.mjs # real-browser suite: own server, fresh database, real LiveKit ~~~ ## Invariants worth protecting 1. The server is authoritative; the client sends intent. 2. Never persist movement per step; never send movement per frame. 3. Media authorisation is enforced by the server/SFU. Hiding or muting a video in the UI is not isolation. 4. Every user-visible string goes through the i18n dictionary. 5. Do not log chat content or secrets. 6. Local development and production configuration must stay clearly distinct. ## Pull requests Keep them focused, include tests for behaviour changes, and update the docs in the same PR. Describe exactly what you ran and what you did not. --- URL: https://guhcostan.github.io/tilework/docs/gauntlet/ # The gauntlet loop How changes are built and reviewed in this repository. Every change, by a person or an agent, runs it before it is called done. The procedure for agents lives in [`.agents/skills/gauntlet/SKILL.md`](https://github.com/guhcostan/tilework/blob/main/.agents/skills/gauntlet/SKILL.md); this page is the why. ## What it is A gauntlet loop separates **building** from **judging**. A builder implements against a written target; critics inspect the result; the builder addresses supported findings and repeats. Research checked on 2026-10-01: - [RoboNuggets, by Jay E, crediting Matt Shumer's technique](https://github.com/robonuggets/gauntlet-loop): separate critics with fresh context and blind comparison against a named, accessible reference. Its stopping rule is winning the comparison or user interruption. - [kamtS/gauntlet-loop](https://github.com/kamtS/gauntlet-loop/blob/main/SKILL.md): bounded worker/critic passes, evidence-backed verdicts and an integration review; its runtime implementation routes workers and critics to different clients. - [Anthropic's evaluator-optimizer pattern](https://www.anthropic.com/engineering/building-effective-agents): generation and evaluation repeat with feedback when criteria are clear and iterative improvement is useful. Our local skill is an original, repository-specific procedure informed by these patterns; no external skill code was copied. We use acceptance tests rather than mandatory blind comparison, a maximum of five rounds, and read-only critics. These are local choices, not requirements shared by all gauntlet implementations. Risks we address in this repository: | Failure mode | Answer here | | --- | --- | | **False independence** | fresh-context critics receive acceptance criteria and raw evidence; fallback passes are labelled self-review | | **Weak acceptance metrics** | criteria describe observable behaviour; bug regressions are seen **red** first | | **Invented findings**: a critic told to find problems finds some | a finding needs severity, `file:line`, a reproduction and the check that would catch it, or it is dropped | | **Regressions across rounds** | record the baseline and run the whole suite at completion | | **Review side effects** | critics are read-only; mutations follow the AGENTS.md rules and the user's authorization | ## Why this repository needs it Two bugs from 2026-10-01 shaped it: - **The pet passed its test and still felt wrong.** The test checked "the pet ends up within 24 px of its owner", which is exactly what a glued, corner-cutting pet does. The fix started from observable criteria (follow the owner's trail ~20 px behind, never cut a corner, never outrun a walker, rest behind and not on top), a frame-by-frame recorder that went red on all four, and a **UX critic** that looked at a captured contact sheet. Metrics alone had already approved the bad pet once. - **Sending a chat message blanked the whole app.** An effect returned `scrollIntoView()`'s value, which Chrome 154 turned into a Promise; React called it as a cleanup and unmounted everything. The browser suite sent chat through the API and never with the panel open. The fix added a real-UI regression check and a static rule in `scripts/lint-web.mjs`, so this bug class is now a gate. ## Gates `scripts/gauntlet.sh` runs formatting, Go vet/race tests, client and test typechecks, the client build, web lint, pure game-logic tests, the docs build and the browser suite with the CI viewport (`CI=1`). Pass scenario names to run only those; the final round runs the whole suite. `GAUNTLET_SKIP_E2E=1` reports a partial static result, never completion. A log in `.gauntlet/last-gates.log` records the tree fingerprint and detects edits during the run. ## Critics | Critic | Lens | Evidence it must produce | | --- | --- | --- | | **UX** | does it *feel* right: motion, timing, layout at 800x450 and on a phone, overlap with other UI | frames or a contact sheet (see `e2e/scenarios/capture-*.mjs`), viewed, plus the scenario run | | **Invariants** | the AGENTS.md invariants: authoritative server, no per-frame sends, bounded queues, no content in logs, consent, audit, i18n, a11y | the `file:line` that breaks one, and the test that would catch it | | **Regression and security** | what else this change can break; input validation, token scope, rate limits | a failing command | ## Limits The loop reviews the requested change; it is not proof that the entire repository is defect-free. A critic verdict complements executed checks. Performance and capacity claims still need their own recorded measurements.