Development Workflow#
This document describes how to develop, test, build, and deploy Yet Another Sim — a server-relayed deterministic FFXIV-style raid simulator built on Bun and Babylon.js 9.
If you only want to author encounters (not change code), start with Authoring Raids instead.
Prerequisites#
- Bun 1.4.2 (the runtime, package manager, bundler, and test runner — no Node
toolchain needed). This is the version pinned by
packageManagerinpackage.jsonand by the Docker image;bun upgradeifbun --versionreports anything older. - For deployment only: Docker + Docker Compose.
Bun is the single toolchain here. package.json has no separate bundler, test framework, or
TypeScript build step — bun build, bun test, and Bun's built-in TypeScript handling cover all
of it.
Local setup#
bun install # install dependencies (uses bun.lock)
bun run dev # build the client bundle, then start the server
Then open http://localhost:3000. Create or join a session, claim a player slot in the lobby, and press play.
Day-to-day loop#
| Command | What it does |
|---|---|
bun run dev | Runs src/server/server.ts with BUILD_ON_START=1, building the client bundle before serving it. |
bun run typecheck | bunx tsc --noEmit — strict type checking across src/** and scripts/**. |
bun test | Runs the engine + server test suites (*.test.ts). |
bun test:parallel | Same suites, but engine + client run across 4 worker processes; server tests stay serial. |
bun run build | Produces a standalone client bundle in .bundle/ (the production path). |
bun run start | Runs src/server/server.ts and serves the prebuilt .bundle/. |
bun run build:analyze | Production build into the gitignored .analyze/, plus a Markdown module-graph report at .analyze/bundle.md. |
A typical change cycle:
- Edit code under
src/. bun run typecheck— the project isstrict, so this catches most issues before runtime.bun test— especially for anything touchingsrc/engine/(see Determinism below).- Reload the browser to see client/render changes.
Development reloads#
bun run dev builds the client once at server startup; reload the browser after client changes.
src/client/main.ts registers an import.meta.hot?.dispose(...) cleanup hook for environments that
provide client-module HMR, but running bun --hot src/server/server.ts alone does not rebuild the
browser bundle or establish a client HMR pipeline.
Project layout#
The codebase is split into four layers by trust boundary and runtime:
src/
shared/ # types, protocol (zod), deterministic math, RNG, constants — imported by both sides
engine/ # the pure deterministic simulation (tick), mechanic systems, raid loading/schema
server/ # Colyseus host: rooms, frame relay, WebSocket transport, metrics
client/ # browser: Babylon renderer, input, netcode, prediction, UI
raids/ # YAML-authored encounters, grouped by category folder
docs/ # this folder
@shared/* is a TypeScript path alias (see tsconfig.json) resolving to src/shared/*. The
engine runs on both the server and every client — that shared execution is the heart of the
networking model below.
How a session works (the data flow)#
This project uses server-relayed deterministic lockstep. Understanding this is essential before touching the engine, netcode, or server.
- Join / lobby.
ColyseusTransportjoins or creates the filteredrelayroom hosted bysrc/server/server.ts.RelayServerRoomowns the Colyseus lifecycle, authentication, rate limiting, and boundary validation; it delegates session behavior to the transport-independentRelayRoom. Players claim slots; one client is the host. - Start. The server builds a tick-0
Worldfrom the raid definition and sends it to clients in astartedmessage, alongside the input log so far. - The relay. The server does not run the simulation.
FrameRelayproduces oneFrameper tick at 60 Hz — each frame is just the merged player intents for that tick (plus a couple of flags). It broadcasts these frames and keeps an authoritative input log. - Client stepping. Each client runs
tick()(src/engine/sim.ts) locally, feeding it the relayed intents plus locally-computed bot intents. Because every client starts from the same seed and applies the same frames in the same order, every client computes a byte-identical world — no world state is streamed during play. - Late join / reconnect / resync. A joining client replays the input log (optionally from a
host snapshot taken every
SNAPSHOT_INTERVALticks) to fast-forward to the room's current tick. - Desync detection. Clients periodically send a
worldHash(HASH_INTERVALticks); the server compares them viaDesyncTrackerand resyncs any client that diverged. - Rendering.
NetClientcoordinates aRenderSnapshotBuffer, which keeps a small snapshot history and interpolates with a fixed render delay for smoothness. The local player is additionally client-predicted (src/client/predictor.ts) so their own movement feels instant — this is render-only and never feeds back into the authoritative world.
host + clients server (Colyseus) every client
intents ───────────► RelayServerRoom / RelayRoom / FrameRelay
merges intents → Frame
broadcasts frames ─────────► tick() locally → identical World
keeps input log ├─ interpolate + render (Babylon)
worldHash ──────────► DesyncTracker └─ predict local player (render-only)
resync if diverged ────────────► replay input log
Determinism: the rule that governs engine changes#
Lockstep only works if tick() is a pure, deterministic function of (world, intents, dt) that
produces identical results on V8, JavaScriptCore, and SpiderMonkey. When editing anything under
src/engine/, respect these invariants:
- No raw transcendentals.
Math.sin/cos/atan2/acosare not required to be bit-identical across JS engines. Use the polynomial approximations insrc/shared/dmath.tsinstead. There is a test guarding this (__tests__/noTranscendentals.test.ts). - Don't reorder systems. The mechanic systems resolve in a fixed order
(
src/engine/mechanicRegistry.ts) because the seeded PRNG (src/shared/rng.ts) is drawn in sequence. Reordering changes RNG outcomes and breaks reproducibility. - No wall-clock, no
Math.random(), no ambient I/O inside the tick. All randomness flows through the seeded RNG carried in the world state. - Keep it serializable. World state is JSON-relayed and hashed; avoid non-serializable fields in the authoritative world (render-only data is attached via a symbol key and excluded from hashing).
The determinism.test.ts and worldHash machinery exist to catch violations — run bun test
after any engine change.
Testing workflow#
- Tests live next to the engine in
src/engine/__tests__/and beside server modules (*.test.ts), and run with Bun's built-in runner:bun test. - The engine is pure, so most tests construct a world, step
tick()a known number of times, and assert on the result — fast and deterministic, no mocks or network. - Add a focused test for any new mechanic system, and prefer asserting on the resulting world over internal state.
- Run a single file with
bun test src/engine/__tests__/towers.test.ts.
Running tests in parallel#
bun test runs serially and stays the default. bun run test:parallel splits the engine and client
suites across four worker processes and then runs the server suite serially, because
src/server/logger.ts opens a FileSink on logs/sim.log at import time — several workers importing
it would append to the same file concurrently. Both commands cover the same 61 files.
Bun can order files by past duration: write per-file timings once and reuse them so the slowest files start first.
bun test --timings=.analyze/timings.json --update-timings # record per-file durations
bun test --parallel=4 --timings=.analyze/timings.json # reuse them: slowest files start first
Profiling#
Bun writes profiles on process exit, so profile a script that ends — a bench harness, or the server
stopped with SIGTERM. --cpu-prof-md also emits a grep-friendly Markdown view of the profile.
bun --cpu-prof --cpu-prof-md --cpu-prof-dir=.analyze/prof <script>.ts # CPU profile + Markdown
bun --heap-prof --cpu-prof-dir=.analyze/prof <script>.ts # .heapprofile
These flags apply to bun <script>; bun test does not emit them.
Building & deployment#
Production bundle#
bun run build writes the browser bundle to .bundle/. In production the Docker image builds this
once, so each worker only serves the prebuilt bundle.
Container#
The app ships as a Docker image (Dockerfile, based on oven/bun:1.4.2 — keep it in step with
packageManager). The tracked
docker-compose.yml is the local single-worker stack.
- Windows / local: create
.envfrom.env.example, setMETRICS_TOKEN(the tracked Compose file requires it even though the application can disable metrics by leaving it unset), thendocker compose up -d --build. - Linux server:
./deploy.sh [branch]— fetches, hard-resets toorigin/<branch>, then runs the gitignored server compose file.
Configuration is environment-driven (see .env.example):
| Var | Purpose |
|---|---|
PORT | Local single-worker HTTP/WS port (default 3000). |
MAX_SESSIONS | Local single-worker room cap. |
METRICS_TOKEN | Guards the Prometheus endpoint; unset disables it outside tracked Compose. |
METRICS_PORT | Local single-worker metrics port (default 9100). |
MAX_CONNECTIONS_PER_IP | Concurrent WebSocket connection cap per client IP. |
MAX_WS_MSGS_PER_SEC | Inbound message rate cap per connection. |
ALLOWED_ORIGINS | Additional comma-separated browser origins allowed to connect. |
LOG_LEVEL | Server logging verbosity. |
OTEL_* | Optional OpenTelemetry tracing; see .env.example. |
Observability#
The server emits Prometheus metrics (src/server/metrics.ts, served by metricsServer.ts).
Per-session replay logs are written to
logs/sessions/*.jsonl (a tick-0 world header plus every frame batch), so any pull can be replayed
offline. This directory grows continuously — rotate it host-side.
src/server/replayReader.ts reads those files as a stream, parsing JSONL record by record with
Bun.JSONL.parseChunk() rather than loading the whole file. Listing therefore counts ticks without
retaining any frames (a 60 MB pull lists in ~88 MB RSS instead of ~285 MB); loading still returns the
full frame array, so it retains what it hands back.
The header carries a replay format version independent of the application package version.
Unversioned or incompatible files remain visible in the replay list but are rejected explicitly
rather than being interpreted as the current World/Frame shape.
Replay review#
Format 2 carries the review payload in the recorded tick-0 world: avoidableSources (the raid's
avoidable: true damage tags) and sections (authored timeline bookmarks). Both are baked by
createWorld, so they travel in the recording and review never depends on the raid file being
unchanged since the pull. Only the current format loads — a recording from any older format stays
visible in the replay list, marked unsupported, and is rejected rather than reinterpreted.
The events themselves are not stored. The server relays frames and never runs tick(), so it has
nothing to record; instead src/client/replayInsights.ts replays the input log once when a replay is
opened and collects the avoidable-hit and death entries the engine emits into world.log
(src/engine/systems/damageLog.ts). Because the engine is deterministic this reproduces exactly the
pull that was recorded, and because ReplayTransport already re-simulates from tick 0 on every seek,
the one-time pass costs less than a single scrub. Nothing is inferred from HP differences, and nothing
is collected again during playback or seeking.
Recorded event ticks follow the transport's seek convention: seeking to an event's tick shows the
state after that event resolved. See Authoring Raids for the
avoidable and sections fields.
Conventions#
- Babylon.js imports must be sub-path / tree-shakeable. Import from specific module paths
(e.g.
@babylonjs/core/Cameras/arcRotateCamera) rather than the barrel@babylonjs/core, and never mix ES6 (@babylonjs/*) and legacy (babylonjs) packages. - The build keeps Babylon's side-effect registrations via
ignoreDCEAnnotations. Babylon wires engine extensions (alpha blending, texture loading, render targets…), scene-loader plugins (glTF), and material shaders through side-effect modules that@babylonjs/coremarks as tree-shakeable. Bun's dead-code elimination would otherwise strip the ones it can't see referenced — and how much it strips varies by Bun version, so a build can silently lose rendering in one environment but not another (no player models, opaque "transparent" materials). Both build paths (BUILD_ON_START=1insrc/server/server.tsand thebuildscript) setignoreDCEAnnotations: true/--ignore-dce-annotations, which keeps every side-effect registration while still eliminating genuinely-unreachable code (~2% larger bundle). This is the single switch that avoids per-feature registration whack-a-mole — prefer it over patching each case. As defense-in-depth (and so a plainbun buildwithout the flag still renders), the engine extensions are also registered explicitly viaRegisterFullEngineExtensions()at the top ofsrc/client/render/BabylonRenderer.ts. - Render layers own their meshes. Each visual family is a
*Layerclass withsync()/dispose();BabylonRenderercomposes them and is the only place that creates/disposes the engine, scene, and camera. Always dispose what you create. - Validate at the boundary. All client→server messages and raid files are validated with zod before reaching trusted code.
- Prefer the existing platform APIs: Colyseus for rooms/transport and Bun-native APIs such as
Bun.build,Bun.file,Bun.Glob, andBun.env.