There is always a path. But never the one you expect.
TRAP PATH is an original, responsive psychological platform game built for the browser. It places the player inside an abandoned experimental facility where floors collapse, exits lie, controls invert, gravity changes, and the camera occasionally becomes part of the trap.
The project uses React for the application shell and accessible interface, Phaser for the deterministic 60 FPS game simulation, and a strict TypeScript data model shared by the game and the in-browser level editor. Its art, level layouts, procedural textures, sound patches, writing, and identity were created for this project.
- Six handcrafted experiments with moving and falling platforms, hidden hazards, portal loops, false checkpoints, fake exits, gravity fields, camera lies, blackout zones, secrets, and environmental lore.
- Walking, running, jumping, wall jumping, coyote time, jump buffering, keyboard, controller, and mobile touch input.
- Responsive indie-game interface with persistent Dark and Light themes, an animated facility backdrop, level select, archive, settings, pause flow, HUD, completion summary, and leaderboard.
- Local progress, settings, statistics, best times, recovered secrets, and automatic resume through a persisted Zustand store.
- Procedurally generated pixel textures and synthesized sound effects, avoiding external copyrighted game assets.
- A visual editor with grid snapping, click or drag placement, object dragging, inspection, deletion, undo/redo, validation, JSON import/export, autosave, and preview snapshots.
- Offline-capable PWA shell and deployment configuration for Vercel, Netlify, and GitHub Pages.
- Optional Supabase leaderboard and cloud save adapters with graceful local-only fallback.
| Layer | Technology |
|---|---|
| Application | React 18, Vite 8, TypeScript |
| Game runtime | Phaser 3 Arcade Physics |
| State | Zustand with local-storage persistence |
| Interface | Tailwind CSS, Framer Motion, Lucide |
| Cinematic animation | GSAP |
| Audio | Howler.js with generated WAV patches |
| Optional backend | Supabase REST API |
| Quality | ESLint 10, Prettier, Vitest 4, strict TypeScript |
- Node.js 20.19+ or 22.12+; CI and production examples use Node.js 22.
- npm 10 or newer.
- A modern browser with Canvas, Web Audio, and ES2020 support.
npm ci
npm run devVite serves the game at http://localhost:4173. Audio starts after the first pointer or
keyboard interaction because browsers block unsolicited playback.
Copy the environment template only if cloud features are wanted:
cp .env.example .env.localOn PowerShell:
Copy-Item .env.example .env.localThe application works without any environment variables.
| Command | Purpose |
|---|---|
npm run dev |
Start the Vite development server on port 4173 |
npm run build |
Run project TypeScript builds and create dist/ |
npm run preview |
Serve the production bundle locally |
npm run typecheck |
Run strict TypeScript checks without emitting files |
npm run lint |
Lint the entire repository with zero warnings allowed |
npm test |
Run the Vitest suite once |
npm run qa:smoke |
Exercise core desktop/mobile flows against a running build |
Before shipping a change, run:
npm run lint
npm run typecheck
npm test
npm run buildKeyboard bindings can be changed from Settings. Arrow keys remain available as a secondary movement option.
The interface theme can be changed at Settings → Appearance → Light theme. The selected theme is saved automatically on the device; gameplay and simulation canvases stay dark for consistent trap visibility.
| Action | Keyboard default | Controller | Touch |
|---|---|---|---|
| Move | A / D or arrows |
D-pad or left stick | Left/right pads |
| Jump / wall jump | Space or up arrow |
South face button | Jump pad |
| Run | Shift |
West face button or right trigger | Hold run pad |
| Retry | R |
UI action | Retry button |
| Pause / resume | Esc or P |
Pause UI | Pause UI |
Movement includes coyote time and jump buffering. A wall jump is available while airborne and touching either wall. Some experiments temporarily reinterpret direction or gravity; that is intentional.
The level definitions live in src/game/levels.ts. Coordinates use the fixed 960×540
simulation height and a horizontally scrolling worldWidth. Every LevelObject.x and
LevelObject.y is the center of the object, matching Phaser's default origin.
| # | Experiment | Primary deception |
|---|---|---|
| 1 | The Courtesy Floor | Falling surfaces and proximity spikes |
| 2 | Dead Load | Moving machinery, switch bridge, false anchor |
| 3 | Echo Routing | Reversed input and linked portal loop |
| 4 | Upward Fall | Gravity inversion and ceiling traversal |
| 5 | Blind Boundary | Blackout, camera lie, false completion |
| 6 | Null Protocol | Combined systems and behavioral misdirection |
To author a built-in experiment, add a LevelDefinition to LEVELS. Keep object IDs unique
inside the level. A targetId must match another object ID when a switch, portal, or false
door needs a destination. Avoid traps that depend on frame rate; use Phaser timers or delta
time.
Open Level Editor from the main menu.
- Choose an object in the palette, then click the canvas, or drag the palette item directly to the desired position.
- Return to Select / move, select an object, and drag it. Placement snaps to a 32-pixel grid.
- Use the Inspector for exact coordinates, dimensions, rotation, timing, movement, target links, messages, and behavioral flags.
- Use Preview to seal an immutable snapshot and invoke the optional gameplay preview callback.
- Export the validated document as JSON. Import accepts editor documents up to 1 MB and rejects malformed versions, unsupported types, duplicate IDs, invalid coordinates, and unsafe dimensions.
Editor shortcuts:
| Action | Shortcut |
|---|---|
| Undo | Ctrl/Cmd + Z |
| Redo | Ctrl/Cmd + Y or Ctrl/Cmd + Shift + Z |
| Delete selected object | Delete or Backspace |
| Nudge selected object | Arrow keys |
| Precision nudge | Shift + Arrow |
| Return to selection tool | Esc |
The working draft autosaves under trap-path-editor-draft-v1. Clearing a draft is undoable.
The exported shape is deliberately small:
{
"version": 1,
"name": "Observation Test",
"worldWidth": 1600,
"spawn": { "x": 96, "y": 450 },
"objects": [
{
"id": "entry-floor",
"type": "platform",
"x": 256,
"y": 516,
"width": 512,
"height": 48
},
{
"id": "exit",
"type": "door",
"x": 1480,
"y": 452,
"width": 44,
"height": 64
}
]
}EditorDocument intentionally contains geometry only. When promoting a community level into
the campaign, wrap its fields in a LevelDefinition and add the world, title, objective,
hint, lore, palette, and modifier metadata.
src/
├── App.tsx React screen orchestration and service wiring
├── audio/ Howler manager and procedural sound patches
├── components/ Menus, HUD, dialogs, archive, mobile controls
├── editor/ Editor UI, history, validation, JSON model
├── game/
│ ├── GameCanvas.tsx React ↔ Phaser lifecycle boundary
│ ├── TrapPathScene.ts Simulation, physics, traps, generated textures
│ ├── input.ts Keyboard/controller/touch input bridge
│ ├── levels.ts Hand-authored immutable level definitions
│ └── events.ts Typed scene-to-React events
├── hooks/ Elapsed time and game-event subscriptions
├── services/ Local/cloud leaderboard and cloud save adapters
├── store/ Persisted global game state
├── styles/ Tailwind layers and atmospheric global effects
├── types/ Shared strict domain contracts
├── ui/ Reusable buttons, panels, fields, logo, helpers
└── utils/ Pure formatting helpers and tests
React owns navigation, settings, overlays, persistence, and responsive layout. Phaser owns the canvas, physics, collision, camera, particles, and frame loop. The two communicate through a typed event emitter and a small input bridge. This boundary prevents the 60 FPS simulation from causing React render churn.
TrapPathScene creates a fresh Phaser instance for the selected experiment. Textures are
generated once per scene palette, while frequently used particles and sounds are reused
through Phaser and Howler. The canvas runs at 960×540 and uses Phaser.Scale.FIT to
preserve gameplay geometry at any display size.
Campaign state persists locally as trap-path-save-v1. Local leaderboard entries use
trap-path-local-scores; the anonymous cloud-save adapter also stores a random device ID in
trap-path-device-id. Editor drafts have their own key and are not included in campaign
saves.
Resetting progress removes campaign progression through the store. Browser site data controls remain the authoritative way to remove every local key and cached PWA asset.
No secret key belongs in a Vite environment variable. Every VITE_* value is embedded in
the client bundle and must be safe to expose.
Set these build-time variables in .env.local and in the deployment provider:
VITE_SUPABASE_URL=https://YOUR_PROJECT.supabase.co
VITE_SUPABASE_ANON_KEY=YOUR_PUBLIC_ANON_KEYNever use the Supabase service_role key in this project. The public anon key is safe to
publish only when Row Level Security (RLS) and restrictive policies are enabled.
The current adapter reads public scores and submits anonymous scores. Run the following in the Supabase SQL editor:
create table if not exists public.trap_path_scores (
id uuid primary key default gen_random_uuid(),
callsign text not null check (char_length(callsign) between 1 and 16),
level integer not null check (level between 1 and 999),
elapsed_ms bigint not null check (elapsed_ms between 0 and 86400000),
deaths integer not null check (deaths between 0 and 1000000),
created_at timestamptz not null default now()
);
create index if not exists trap_path_scores_rank_idx
on public.trap_path_scores (level, elapsed_ms, deaths);
alter table public.trap_path_scores enable row level security;
create policy "Scores are publicly readable"
on public.trap_path_scores
for select
to anon, authenticated
using (true);
create policy "Clients may submit bounded scores"
on public.trap_path_scores
for insert
to anon, authenticated
with check (
char_length(callsign) between 1 and 16
and level between 1 and 999
and elapsed_ms between 0 and 86400000
and deaths between 0 and 1000000
);Client-submitted times are inherently untrusted. For a competitive leaderboard, send completions to a rate-limited Edge Function, validate a signed replay or run token server-side, and let only that function insert scores.
The included adapter can upsert a save by a random device ID without a login. That provides convenience, not identity or confidentiality: anyone who learns a device ID could read or replace that row. Game progress is not sensitive, but this model must not store personal data.
For a development/demo deployment compatible with the current adapter:
create table if not exists public.trap_path_saves (
device_id text primary key check (char_length(device_id) between 20 and 80),
save_data jsonb not null
check (octet_length(save_data::text) <= 65536),
updated_at timestamptz not null default now()
);
alter table public.trap_path_saves enable row level security;
create policy "Demo device saves are readable"
on public.trap_path_saves
for select
to anon
using (true);
create policy "Demo device saves can be created"
on public.trap_path_saves
for insert
to anon
with check (octet_length(save_data::text) <= 65536);
create policy "Demo device saves can be updated"
on public.trap_path_saves
for update
to anon
using (true)
with check (octet_length(save_data::text) <= 65536);For production, use Supabase Auth, create a user-owned table, send the user's access token
instead of the anon token in Authorization, and update the adapter to address rows by
user_id:
create table if not exists public.trap_path_user_saves (
user_id uuid primary key references auth.users(id) on delete cascade,
save_data jsonb not null
check (octet_length(save_data::text) <= 65536),
updated_at timestamptz not null default now()
);
alter table public.trap_path_user_saves enable row level security;
create policy "Players read their own save"
on public.trap_path_user_saves
for select
to authenticated
using ((select auth.uid()) is not null and (select auth.uid()) = user_id);
create policy "Players create their own save"
on public.trap_path_user_saves
for insert
to authenticated
with check ((select auth.uid()) is not null and (select auth.uid()) = user_id);
create policy "Players update their own save"
on public.trap_path_user_saves
for update
to authenticated
using ((select auth.uid()) is not null and (select auth.uid()) = user_id)
with check ((select auth.uid()) is not null and (select auth.uid()) = user_id);Remove the demo table or revoke its anon policies when moving to authenticated saves. Review the Supabase RLS guide before exposing any additional table.
The production build registers public/sw.js. The worker:
- Pre-caches the application entry, manifest, offline page, and original SVG icons after a successful first load.
- Uses network-first navigation so a deployment is not permanently hidden behind an old shell.
- Uses stale-while-revalidate for same-origin scripts, styles, fonts, images, audio, and workers.
- Never caches non-GET requests, range requests, cross-origin Supabase traffic, or
/api/responses. - Deletes older TRAP PATH caches during activation.
The service worker runs only in production. Test it with:
npm run build
npm run previewUse a private window or clear Application → Storage in browser developer tools when testing install/update behavior repeatedly. PWA installation and service workers require HTTPS, except on localhost.
When changing the app-shell caching strategy, increment CACHE_VERSION in public/sw.js.
Always add the optional Supabase variables in the provider dashboard before the build. They are build-time Vite variables, not runtime server secrets.
- Import the repository as a Vite project.
- Leave the build command as
npm run buildand output directory asdist. - Add environment variables if cloud features are enabled.
- Deploy.
vercel.json contains the SPA rewrite, immutable hashed-asset caching, and must-revalidate
headers for the service worker and manifest.
- Import the repository.
- Netlify reads
netlify.toml: Node 22,npm run build, anddist. - Add optional environment variables and deploy.
The catch-all 200 rewrite supports SPA navigation while Netlify's normal shadowing behavior
continues to serve real static files such as sw.js.
- Push the project to a repository whose default branch is
master. - Open Settings → Pages → Build and deployment and choose GitHub Actions.
- Push to
masteror run Deploy to GitHub Pages manually.
.github/workflows/deploy-pages.yml installs with npm ci, builds with the repository name
as Vite's base path, uploads dist, and deploys through the official Pages artifact flow.
public/.nojekyll ensures files are served without Jekyll processing.
For a user/organization root site or a custom domain, change the workflow's BASE_PATH to
/. See
GitHub's custom workflow guide.
- Every menu and editor action is keyboard reachable and has a visible focus state or accessible label.
- Settings include reduced motion, screen-shake control, high contrast, color remapping, remappable keys, and independent audio levels.
- CSS also honors the operating system's
prefers-reduced-motionpreference. - The game uses a fixed-step Arcade Physics loop, pixel-aligned rendering, generated textures, lazy-loaded Phaser/editor chunks, and an isolated canvas to avoid unnecessary React work.
- Touch targets account for mobile safe areas; the viewport keeps its 16:9 geometry and scales without changing collision coordinates.
For a fair level, test each trap with reduced motion enabled, keyboard-only navigation, a narrow mobile viewport, and at both 30 and 60 FPS.
- Add a trap by extending
TrapObjectType, adding its visual/physics construction inTrapPathScene, defining its editor palette defaults and validation, and then adding focused tests. - Add a campaign level in
src/game/levels.ts; do not mutate existing definitions at runtime. - Add a service behind the interfaces in
src/services/; gameplay must retain a local fallback. - Keep simulation work in Phaser and interface work in React. Communicate across the boundary with typed events rather than DOM queries or global mutable state.
The guiding rule is simple: surprises may be cruel, but collision, timing, input, and recovery must remain deterministic enough for the player to learn.