Next.jsPixel UILiveKit

Cozy Corner, A Warm Third Space

A design-engineer exploration of real-time social presence: chat, voice, avatars, and a shared world wrapped in a tactile pixel UI.

Role

Design Engineer

Timeline

3 Days

Team

Solo

Summary

Cozy Corner is a small-group messaging app dressed like a cozy RPG village. Friends sign in, customise a pixel avatar, chat in a layered ocean scene, wander a shared top-down world, and drop into voice rooms together. The goal was not scale — it was to prove that social software can feel warm, tactile, and game-like without sacrificing real-time reliability.

Every surface was designed and built end-to-end: design tokens, component library, sprite pipeline, presence model, and the realtime layers underneath.

Login — animated sky scene, drifting clouds, and a wood-panel auth shell

Tech stack

A modern React stack with Supabase as the realtime backbone and LiveKit for voice.

Frontend
Next.js 16 · React 19 · TypeScript · CSS Modules

App Router with client islands for canvas, chat, and voice. Global design tokens in globals.css — forest/moss/cream palette, 8pt grid, Press Start 2P + VT323 typography.

Backend
Supabase · Postgres · Realtime · Storage

Auth (Google OAuth + email), profiles, messages, world positions, and session presence. Postgres changes and broadcast channels drive live updates.

Voice
LiveKit · livekit-server-sdk

Server-minted JWT tokens via /api/livekit-token. Audio-only room with speaking-state visual feedback on avatar seats.

Motion
Framer Motion · Canvas 2D

Spring-based tactile buttons. Raw Canvas API for world rendering and procedural emoji pixelation — no game engine in the loop.

Design system

Cozy Corner’s design system is built around a single question: what if group chat felt like a shared hangout, not filing a ticket? The answer is “Cozy Pixel UI” — warm, tactile, and game-adjacent, but still a real web app underneath.

Design philosophy

The philosophy is comfort over chrome. Social software often optimises for information density; this project optimises for emotional temperature — presence, playfulness, and the sense that a space is shared. Pixel art and wood-panel framing are not nostalgia for its own sake; they signal “third place,” the same way a café’s lighting signals “stay awhile.”

Three principles run through every screen:

  • Tactile first — if you can click it, it should look pressable. Hover brightens, tap compresses, panels have inset depth. Feedback is physical, not abstract.
  • Restraint in motion — ambient layers (clouds, ocean sway, scene parallax) loop quietly in CSS; UI transitions stay short and springy. Motion supports mood without competing with readability.
  • One village, many rooms — auth, chat, profile, and voice are different features, but they share type, colour, border weight, and spacing so the app never breaks character when you navigate.

Typography reinforces the tone: Press Start 2P for display labels (titles, nav, panel headers), VT323 for readable body and chat copy at a larger pixel scale. The split keeps headers unmistakably retro while messages stay scannable in long threads.

Traditional web layout + 9-slice pixel art

The system deliberately mixes two idioms: conventional responsive web structure on the inside, pixel-game surface treatment on the outside. The app shell is familiar web design — a 2:8:2 column grid (sidebar · main · roster), flex stacks for auth forms, scrollable chat, accessible focus states, and standard input patterns. None of that is reinvented.

What is reinvented is the skin. Panels, buttons, and cards borrow from game UI via 9-slice-style borders: corner and edge slices from small PNG tiles, scaled with border-image so wood-grain frames and pixel outlines stretch to any panel width without distorting corners. PixelPanel ships wood and standard variants; PixelButton pairs with them on login and onboarding so the form reads as a carved sign, not a default HTML fieldset.

The hybrid shows up everywhere:

  • Layout — CSS Grid and flex for structure; pixel borders and hard drop shadows for character.
  • Imagery — chat backgrounds and avatars are layered PNG sprites with image-rendering: pixelated; the message list itself is still a normal scroll container with real text nodes.
  • DepthPhysicalCard uses offset shadows (no blur) like inventory tiles; content inside is standard React markup.
  • InteractionTactileButton wraps semantic <button> elements with Framer Motion scale/brightness; accessibility and keyboard behaviour stay web-native.

The goal is not to build a game engine UI — it is to let web UX patterns (roster, threading, mute toggles) carry the usability while pixel art carries the feeling. You get Discord’s clarity dressed in a cozy RPG village.

Semantic design tokens

All visual decisions flow through CSS custom properties in globals.css. Components never reference raw hex values in their modules — they reference semantic roles, so iteration happens in one place and every screen updates together.

Tokens are organised in three layers:

  1. Primitives — base steps on an 8pt spacing scale, type sizes (--text-xs--text-2xl), and raw palette anchors.
  2. Semantics — role names that describe intent, not appearance: --moss (primary interactive fill), --cream (readable surface), --forest (deep backdrop), --sky (ambient highlight). Renaming “moss” from green to teal would not require hunting through component files.
  3. Component aliases — composed tokens such as --shadow-drop (hard card elevation), inset shadow pairs for pressed inputs, and a shared --border-pixel weight for the 2px outline repeated on panels, swatches, and roster chips.

Status and emphasis sit in scoped variants — danger for mute/disconnect, a leaf-toned online marker instead of a generic green dot — so functional colour never pollutes the neutral village palette. Chat scrims use a dedicated transparency token so parallax backgrounds stay visible while VT323 body text keeps contrast.

Layout primitives (Stack, Box, PageContainer) consume spacing tokens directly, which means new auth or settings screens inherit rhythm by default. RevealText, swatch grids, and voice seat rings all pull from the same semantic set — the system stays small (~a dozen composed components) because tokens do the heavy lifting.

Hand-drawn art

Every visual asset in the app is hand-drawn pixel art — character sprite sheets, chat backgrounds, 9-slice UI tiles, and scene layers. Some I drew myself; others were commissioned from pixel artists for specific sets. No AI-generated art was used anywhere in the project — the village’s warmth comes from human line work and deliberate colour choices, not generative fills.

That constraint shaped the pipeline: sprites ship as authored PNG sheets, backgrounds stack as layered parallax PNGs, and UI skins use repeatable hand-painted border slices — all tuned by eye, not upscaled from models.

Philosophy sets the mood, the web + 9-slice hybrid delivers usability without breaking immersion, and semantic tokens keep both aligned as features ship. Add a chat background, avatar layer, or voice seat and you extend the village — you do not redesign it.

Features & how they were built

Auth & onboarding

Login sits on an animated sky scene — drifting cloud sprites, grass horizon, wood panel form. Supabase handles Google OAuth and email/password; new users land on onboarding where they must create a character before entering chat. An AuthContext wraps the app shell, fetches profiles, and runs a session heartbeat so the roster knows who is online.

Layered sprite avatar system

Character appearance is data-driven. A single sprites.ts registry maps hair, tops, bottoms, shoes, and headwear to authenticated sprite-sheet paths (~130 assets). CharacterSprite stacks layers with CSS background-position animation across a 12-frame walk cycle. CharacterAvatar adds variant-specific crops — roster (head only), chat (chest-up), panel, preview — so the same config reads correctly at every size.

Signed CDN URLs are prefetched once on mount via SpriteUrlContext (cached in sessionStorage) to avoid per-image server roundtrips. Fallback proxy routes serve sprites when CDN isn’t ready.

Profile customizer & chat backgrounds

The profile page splits into a live preview (avatar on a parallax ocean scene) and a tabbed customizer — hair, top, bottom, shoes, hat with style + colour swatches. Eight selectable chat backgrounds persist to the profile; each scene is a stack of PNG layers (sky, swaying middle rocks, ground) rendered by ChatSceneBackground with CSS float animations.

Profile customizer — live preview on a parallax ocean scene with tabbed outfit controls

Real-time chat

Messages live in Supabase Postgres; inserts stream in via Realtime subscriptions. The UI implements Discord-style message chaining — consecutive messages from the same user within 20 seconds collapse into one thread, showing the avatar only on chain start and hover timestamps on follow-ups.

Input is a contentEditable div with markdown-lite transforms (**bold**, *italic*) and keyboard shortcuts.

Chat — layered ocean background, roster sidebar, and chained message threads

Native emoji → pixel art pipeline

The chat renders stored HTML with dangerouslySetInnerHTML, but raw Unicode emojis would break the pixel aesthetic — they arrive as smooth, OS-specific colour glyphs. Instead, every emoji is converted at display time through a two-stage client-side pipeline: detect → rasterise → threshold → cache → scale.

1. Detection. parseEmojisToHtml() runs the message body through the emoji-regex package, which matches standard Unicode emoji sequences (including ZWJ compounds like 👨‍👩‍👧). Each match is replaced with a small HTML fragment — not the raw character.

2. Canvas rasterisation. For each match, getPixelatedEmoji() creates an off-screen 16×16 canvas. The native emoji is drawn with fillText() using the OS emoji font stack — Apple Color Emoji, Segoe UI Emoji, Noto Color Emoji — at 13px, centred on the canvas. Rendering at 16×16 first (rather than drawing large and downscaling) keeps the source resolution intentionally low, which is what gives the retro pixel look once scaled up.

3. Alpha thresholding. Browser emoji rendering is anti-aliased by default — soft edges that look wrong at pixel scale. The pipeline reads back the canvas pixel data and hard-thresholds the alpha channel: any pixel below 50% opacity becomes fully transparent, anything above becomes fully opaque. That single pass removes fringe blur and produces crisp, game-like edges without hand-authoring sprite sheets for every emoji.

4. Cache & inject. The result is exported as a base64 PNG data URL and stored in an in-memory Map keyed by the Unicode string — the same emoji never hits the canvas twice in a session. The HTML output wraps it in a pixel-emoji-wrapper span with an <img> inside, preserving the original character in alt and title for accessibility.

5. CSS upscaling. The image is displayed at 1em × 1em with image-rendering: pixelated, which tells the browser to use nearest-neighbour interpolation instead of smoothing — so the 16×16 bitmap reads as chunky pixel art inline with body text. Vertical alignment is tuned (vertical-align: -0.2em) so emoji sit on the text baseline rather than floating above it.

Pixel emojis inline with body text — native Unicode glyphs rasterised to 16×16 and upscaled with nearest-neighbour

The pipeline runs client-side only — during SSR or if canvas fails, it falls back to the native Unicode character. No emoji sprite atlas to maintain; any emoji a user types on any OS automatically inherits the Cozy Pixel look.

Shared world (Canvas)

“The World” is a 40×30 tile map drawn entirely with the Canvas 2D API — grass, paths, trees, pond, subtle grid. Players move with WASD/arrows; characters are drawn procedurally from the same config colours used in chat (hair styles, outfit, hat emoji, direction-aware eyes).

Movement uses a hybrid sync strategy: WebSocket broadcast every 100ms for smooth peer updates, throttled Postgres upserts every 5s as backup, and a debounced final save when movement stops — keeping disk I/O low while movement feels instant.

Voice room

Voice uses LiveKit audio-only. Twenty-five seat slots ring the room in polar coordinates — each participant hashes to a fixed seat; when they speak, their layered sprite animates. Mute/unmute toggles the local mic; leaving tears down the room cleanly.

Presence & member roster

A three-column app shell — sidebar nav, main content, roster directory. The roster lists all profiles split online/offline, driven by a sessions table with Realtime subscriptions. Online users get a leaf marker (✿) instead of a generic green dot, keeping the nature theme consistent.

Build & deploy pipeline

The app ships as a standard Next.js production build — no custom CI config in-repo, but a deliberate asset and auth pipeline around it.

Local dev → production build

Four npm scripts cover the loop: next dev for local work, next build + next start for production, and eslint via eslint-config-next. TypeScript is strict end-to-end. next.config.ts whitelists Supabase image hosts, marks livekit-server-sdk as a server external package, and keeps dev indicators off.

Deploy target

Built for Vercel — the default Next.js host. Environment variables (NEXT_PUBLIC_SUPABASE_*, SUPABASE_SERVICE_ROLE_KEY, LIVEKIT_*) are injected at deploy time; no secrets live in the repo.

Sprite asset pipeline

Character art never ships from /public. The flow is:

  1. Local source — PNG sheets live in assets/sprites/ during development; /api/sprites serves them from disk.
  2. Upload scriptnode scripts/upload-sprites.mjs recursively pushes every PNG to a private Supabase Storage bucket (upsert-safe, re-runnable).
  3. Production serve — the same API route falls back to Storage downloads when local files aren’t present.
  4. Batch signing — on app mount, GET /api/sprite-urls batch-signs ~130 URLs (2h TTL); the client caches the map in sessionStorage and preloads images so the customizer stays instant.

Auth middleware & database

A Next.js middleware proxy runs on every non-static route, refreshing Supabase session cookies via getSession() (cookie-only, no network round-trip — avoids Vercel edge timeouts). Unauthenticated users redirect to /login; authenticated users skip it. Schema changes ship as Supabase SQL migrations (e.g. chat_background column on profiles).

Architecture notes

  • Single source of truth for sprites — add one entry to sprites.ts, customizer and renderer pick it up automatically.
  • Protected assets — character art never ships from /public; an authenticated /api/sprites proxy gates access.
  • Realtime-first, DB-second — chat and world prioritise Supabase channels; Postgres is persistence, not the hot path.
  • Design-engineer loop — optical centering debug hooks on the profile preview, variant maps for avatar crops, and CSS token-driven layout ratios (2:8:2 sidebar grid).