HALCYON-7 · ENG
▶ ENGINEERING NOTES · SERVER-AUTHORITATIVE · DATA-DRIVEN

A cozy hangout,
built on boring tech
that scales sideways.

Halcyon-7 is a browser-based 2D social game — PixiJS in React, Colyseus for realtime, Postgres + Redis behind a stateless web tier. Small on purpose, but architected so nothing here precludes growth.

01 / GUIDING PRINCIPLES

Three rules that settle architecture arguments.

Every contested call gets decided by these before anyone opens an editor.

RULE 01

Boring tech wins

Every component is something we can debug at 2am. No bleeding edge without a load-bearing reason. Postgres, Redis, Node — things we already know cold.

RULE 02

Data is the contract

Items, rooms, badges, outfits — all defined in versioned data files, referenced by stable IDs like item:hat_pirate_001. Code reads data; data never depends on code shape.

RULE 03

Server owns truth

Client renders, predicts, animates. Server owns position, inventory, currency, chat, friend state. Anything the client claims is suspect until the server confirms it.

02 / THE STACK

Every layer is a thing you can already reason about.

One realtime monolith, one web monolith, one database. Modular code, not modular deploys.

Layer
Choice
Why
Client rendering
PixiJS v8
Composes cleanly inside React — the game canvas is one route, everything else is UI shell.
App shell
Next.js 15 · App Router
Auth, profile, shop, admin all benefit from server components. Game canvas mounts on one page.
Realtime
Colyseus 0.16 · Node 22
Room/state sync, delta compression, reconnection, matchmaker — all included, all battle-tested.
Auth
Supabase Auth + token bridge
OAuth + email/password for free. A bridge mints short-lived tokens the realtime server verifies.
Database
Postgres 16
Boring, reliable, well-understood. The right composite indexes from day one.
ORM
Drizzle
Type-safe, migration-friendly, no magic. Schema lives in one package.
Cache / presence
Redis 7
Online-status with TTL, rate limits, cross-process pub/sub for moderation. Never an in-memory Map.
Object storage
MinIO · S3-compatible
Serves content-hashed atlases. Swap endpoint to any S3 target with one env change.
Proxy / deploy
Traefik · Dokploy
Handles the WebSocket upgrade for Colyseus. Docker Compose under the hood.
Observability
/healthz + /readyz · pino · Sentry
Liveness vs readiness split, structured JSON logs one-per-line, error tracking. No pre-built dashboards.
Monorepo
pnpm + Turborepo
Shared types package is the single source of truth for the client↔server protocol.
03 / SCALE STANCE

"Don't preclude scale" — not "design for scale."

MVP target is 20–50 concurrent. The rules below are cheap now and expensive to retrofit, so we follow them from day one — without paying the complexity tax of building for thousands today.

CHEAP NOW, MANDATORY

  • Stateless web tier — no per-request state in memory
  • Room state lives in Colyseus only, never in the DB while active
  • Every join routes through the matchmaker abstraction
  • Presence & rate limits in Redis with TTL, not a Node Map
  • Composite indexes on the queries we already know
  • Content-hashed asset delivery, CDN-shaped
  • No singleton that would shatter across processes

ONLY IF/WHEN NEEDED

  • Multi-process Colyseus (flip on past ~300 CCU)
  • Read replicas & connection pooling (at first slow-query alert)
  • Microservices (never planned — modular code instead)
  • Dedicated message queue for chat fanout
  • CDN edge caching & image resizing service
  • Prometheus / Grafana / distributed tracing

Revisit trigger: when sustained concurrency crosses 200, or any room routinely saturates its soft cap. Not before.

04 / THE ASSET PIPELINE

The part that makes or breaks the project.

Game code never references a filename or sprite index — only stable IDs. Swapping a placeholder for commissioned art is a content commit, not a code change and not a migration.

DESIGN GOALS

  • Stable IDs — always room:cantina, never a path
  • Hot-swap — new PNG in the same folder, rebuild, done
  • Validated at build — Zod checks every content file; a broken manifest never ships
  • Versioned — manifest carries a content hash; atlases cached forever, busted by hash
  • Pre-packed — the build packs atlases so the browser fetches a few files, not 200

THE BUILD STEP

  • Discover — walk the content tree for every descriptor
  • Validate — each file through its schema, fail loudly
  • Pack — sprites into atlases by category; big room backgrounds stay loose
  • Emit manifest — the contract everything reads from
  • Hash — content hash per atlas for cache-forever delivery
  • Sync — upsert catalog into Postgres. Items leave the shop, are never deleted — owners keep them
// a hat is a JSON descriptor + a PNG. day one it's a colored rectangle.
{
  "id": "hat_pirate_001",
  "kind": "hat",
  "slot": "head",
  "displayName": "Pirate Tricorn",
  "price": 250,
  "sprites": {
    "icon": "./icon.png",
    "directional": { "north": "./sprites/north.png", "south": "./sprites/south.png" }
  }
}
// day 90: drop in commissioned PNGs, rebuild. no code, no migration.
05 / THE WIRE PROTOCOL

Server-authoritative click-to-walk, the part everyone botches.

The network carries intent and a path — never 60Hz position updates. Server state is always "where the player ended up," not "where they are mid-step," which kills interpolation drift on the authoritative side.

C → S

walk_to · { x, y }

Client requests a target tile. Server runs A* on the room's collision mask (loaded once at room create), validates bounds and reachability, and sets state to the final destination immediately.

S → C

path_announced · { characterId, waypoints, startedAt, durationMs }

Broadcast to everyone in the room. All clients animate that player walking the same waypoints. Animation is purely client-side.

S → C

path_canceled · { characterId, atX, atY }

Sent if the player is muted, kicked, or redirects mid-walk. Reconnect is trivial — a joining client just gets current state, with no in-flight paths to reconcile.

C → S

chat · { body }

Server rate-limits, runs the profanity filter, checks mute, persists for moderation audit, then broadcasts a short-lived chat bubble into room state — and pushes to a Redis channel for the mod tools.

06 / DATA & ROOMS

Ephemeral state stays in the room. Durable state flushes to Postgres.

The database never knows a user is "in the cantina at tile (12,8)." That belongs to the room process. When a player leaves, inventory and currency flush; position simply evaporates — which is exactly what lets rooms move between processes later with zero migration.

ROOM TYPES

hub — one instance per public room (arrivals, cantina, observation). Persistent even when empty, soft cap 25.

cargo_sort — single-player, ephemeral. Created per session, disposed on leave. Keeps the connection lifecycle uniform.

DURABLE MODEL (SKETCH)

  • profiles — username, role, status; one per user
  • characters — equipped slots as jsonb, credits; one per profile
  • inventory — owned items, unique per (character, item)
  • friendships — symmetrical, stored as one sorted row
  • events_log — append-only audit of every mutating action

Currency is just an int on the character — no speculative ledger. If we ever need history, it derives from events_log, which is also the answer to every "I lost a hat" support ticket.

07 / BUILD ORDER

Foundation first. Each phase is independently shippable.

The hardest part — the plumbing — comes first, while motivation is high. Later phases are mostly content, which is the fun kind of work.

P0

Plumbing

Monorepo, auth + session bridge, profiles/characters, first Drizzle migration, one trivial Colyseus room, health endpoints and structured logs.

done when → two browsers log in and see each other join/leave in server logs
P1

Walking around

Pixi canvas in Next.js, asset pipeline v1, click-to-walk with A*, other players visible with names, room transitions, three rooms in placeholder art.

done when → two friends walk three rooms and see each other
P2

Talking & identity

Room chat with bubbles, profanity filter + rate limit + mute + report, profile cards, friends with online status and "find," DMs.

done when → a stranger could mistake the chat for a finished game's
P3

Stuff to do

Items catalog, inventory, shop terminal, avatar customization, passive + minigame currency, badges, and Cargo Sort polished single-player.

done when → five testers want to log in tomorrow
P4

Operating the game

Admin panel: report queue, user lookup, mute/kick/ban propagated live over Redis, audit log viewer, seasonal event system, backup verification.

done when → you can handle a misbehaving user in 60 seconds from your phone
P5

Soft launch

50-person stress test, fix what breaks, invite-code signup, gradually open. v2 (bunks, multiplayer minigames, leaderboards) only if the MVP earns it.

▶ THE DOCS ARE THE PRODUCT

VISION decides what. ARCHITECTURE decides how. ADRs record why.

Nothing gets built until there's a PLAN.md. Small reviewable commits, explicit sign-off between phases. If you like plan-before-code discipline, you'll feel at home.

Back to the top Re-read the stack