Skip to content

manix84/time-pilot

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

450 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

🕹️ Time Pilot

Time Pilot is a modernized React + TypeScript rebuild of an older browser-game prototype. The game still renders through a pixel-art canvas engine, but it now lives inside a Vite application with typed game modules, React lifecycle integration, SCSS module styling, CI checks, GitHub Pages deployment, and release automation.

🕰️ Project History

Time Pilot started as a browser-game experiment on September 13, 2012. Work paused on November 3, 2013, leaving the prototype unfinished for more than a decade. I picked it back up on May 11, 2026, with the goal of finishing the game properly while preserving the original canvas-era feel.

✨ Highlights

  • 🎮 Canvas-based arcade gameplay hosted inside React.
  • ⚛️ Thin React integration via useTimePilot.
  • 🧠 Typed game engine modules with explicit game context injection.
  • 🎨 Component-scoped SCSS modules for the React shell, canvas host, and update overlay.
  • 🧱 Feature-oriented source layout under src/game.
  • 🧪 Vitest coverage for engine helpers, controllers, menus, factories, the game host, and React integration.
  • 🎛️ Keyboard, touch, and gamepad controller support.
  • 🧭 Canvas-rendered start/options/debug menus with keyboard, gamepad, mouse, and touch interaction.
  • 🔎 UI zoom and game POV zoom with automatic viewport scaling.
  • 🏆 Achievement tracking with an achievements page, progress counters, and unlock popups.
  • 🎬 Startup preroll with the author logo, Time Pilot flyby, menu-logo handoff, and instant skip input.
  • 💾 Session restore that skips the preroll and returns interrupted runs to a paused Continue menu.
  • 🏅 Local-first high scores with optional remote sync through PostgreSQL or JSON-backed API storage.
  • 📊 High-score stat cards track accuracy, enemies, loops, near misses, restarts, bonuses, and survival time.
  • ℹ️ Public about page with version, host, privacy, source, and sponsorship links.
  • 🛠️ Debug tools for level select, preroll replay, runtime logging, and stored-data resets.
  • 📚 Production Storybook reference under /stories/, including a sprite gallery.
  • 📱 Installable offline PWA mode that launches as a standalone app and enters fullscreen play from Start/Continue.
  • 🔆 Optional PWA keep-awake mode, on by default, for active or paused player runs.
  • 👆 Touch steering guide, enabled by default, that appears only while the active gameplay touch is held.
  • 🎵 Looping menu and era music with faded track transitions, routed through the music volume channel.
  • 🔁 Root-menu update flow that applies waiting PWA updates without interrupting play.
  • 📺 Optional CRT/VHS filter presets with custom sliders.
  • 🌍 Localized menus, level blurbs, and level showcase labels.
  • ✅ PR checks for tests, lint, and type checking.
  • 🚀 Automatic GitHub Pages deployment from main.
  • 🏷️ Automatic GitHub Release creation from the current package version.
  • 🔢 Local pre-commit version bumping based on staged changes.

📸 Screenshots

Preroll flyby Achievement unlock Root menu
Time Pilot preroll flyby Time Pilot gameplay with achievement popup Time Pilot root menu over attract demo
The skippable preroll brings in the logo with a player flyby. Arcade play with HUD details, credits, and achievement unlocks. The root menu sits over the attract demo with submenu chevrons.

🚀 Quick Start

Install dependencies:

npm install

Run the development server:

npm run dev

Build for production:

npm run build

Preview the production build:

npm run preview

Run the optional high-score API:

npm run api

During local development, Vite proxies /api/* to http://localhost:8787 by default. If the default API port is busy, npm run api will automatically try the next available port and write the URL for Vite to use, so /api/* keeps working without manual setup. Set HIGH_SCORE_API_URL before npm run dev only if the API is running somewhere outside the local helper server. Set VITE_API_MODE=offline when building a static/local-only release that should not try to contact backend APIs.

Builds also publish the Storybook reference site to dist/stories, so the deployed site exposes it under /stories/. The showcase page links to that route for sprite, menu, preroll, achievement, audio, and UI reference views.

🏅 High Score Storage

High scores are local-first. The game immediately saves submitted scores to localStorage, so score entry works offline and without any backend. When the high-score API is reachable, scores from online player runs are synced to the remote table and merged back into the local leaderboard. If the API is absent, blocked, or offline, the game falls back to local scores, shows the satellite connection indicator in its offline state, and periodically probes for the API to return. The same indicator also shows waiting, syncing, and brief success states while the score table is checked or updated.

The API uses PostgreSQL when DATABASE_URL is configured:

DATABASE_URL=postgres://user:password@localhost:5432/time_pilot \
HIGH_SCORE_SECRET=replace-with-a-long-secret \
npm run api

npm run api loads .env.local and .env before it starts, without overwriting variables already provided by the shell or host platform. A typical local setup is:

DATABASE_URL=postgres://time_pilot:time_pilot@localhost:5432/time_pilot
HIGH_SCORE_SECRET=replace-with-a-long-random-secret
# DATABASE_SSL=true

For DigitalOcean App Platform, set these app-level environment variables:

Variable Required Purpose
DATABASE_URL Yes PostgreSQL connection string for the Time Pilot database/user. If DigitalOcean includes sslmode=require or another ssl* URL option, leave it in the URL.
HIGH_SCORE_SECRET Yes Stable long random secret used to sign high-score run receipts. Changing it invalidates outstanding receipts.
CORS_ORIGIN Recommended Public Time Pilot site origin, such as https://manix84.github.io, when the API is hosted on a different origin.
PORT Platform-provided The API listens on this when provided, otherwise it starts at 8787 locally and auto-tries later ports if busy.
DATABASE_SSL Only if needed Set to true when the hosted database requires TLS but the URL does not contain sslmode=require or another ssl* option. Set to strict only when Node can verify the provider certificate chain.

If DATABASE_URL already contains an SSL option, that URL wins and DATABASE_SSL is ignored. This avoids accidentally overriding hosted-provider connection strings that already include their SSL requirements.

For a single DigitalOcean Web Service that hosts both the game and the high-score API, use:

Build command: npm run build
Run command: npm start

npm start serves the built dist/ site and the /api/high-scores endpoints from the same Node process, so visiting the DigitalOcean app root should open the Time Pilot site rather than the JSON API fallback.

If PostgreSQL is unavailable or not configured, the API falls back to data/high-scores.json. That fallback is useful for local development and small self-hosted installs; production deployments should use PostgreSQL and a stable HIGH_SCORE_SECRET. Optional environment variables include PORT and CORS_ORIGIN.

Remote score submission is best-effort secure. The client asks the API for a single-use signed run receipt at the start of an online run, then includes that receipt when a score is submitted. The server checks the receipt, expiry, single-use status, score shape, and basic score/stat plausibility before storing the score. Pending local submissions also carry a lightweight integrity envelope so casual local-storage edits are downgraded to local-only before sync, and the API rejects submissions whose envelope no longer matches the score payload. Offline runs still save locally, but they do not receive a remote run receipt and are treated as local-only rather than trusted remote submissions. Runs that use debug mode are also kept local-only, even if debug is enabled part way through the session. See PRIVACY.md for the backend data-storage breakdown.

📱 Installable PWA

The production build includes a web app manifest and service worker. Installed launches use the dedicated /pwa/ endpoint, which renders only the game canvas. The manifest uses standalone display mode with landscape orientation so Android identifies the install as an app, then the game requests fullscreen and a landscape orientation lock from the player's Start/Continue action where mobile browsers allow it.

The service worker caches the app shell plus core game sprites, fonts, and sounds so the installed game can continue to run offline after it has been installed or loaded. On the /pwa/ game route, the app checks for a new service worker on load, reconnect, and tab focus. Updates wait in the background and are applied only from the non-playing root menu through the Update button, followed by the player time-warp animation and a reload.

When the game is running as an installed PWA, the root menu also shows an Exit action. It asks the browser to close the app window; platforms that do not allow scripted app closure may ignore the request. If that happens, Time Pilot confirms the run has been saved and tells the player to leave with the device Home or Back button.

Installed PWA options also include Keep Screen Awake. It is enabled by default and uses the browser Screen Wake Lock API during active or paused player runs so the display does not sleep mid-game. It releases automatically outside real gameplay, such as demo, game-over, reset, and teardown states, and silently falls back on browsers that do not support wake locks.

Installed PWA back navigation is mapped onto the game: OS Back returns through open submenus, resumes play from the paused root menu, and exits from a fresh root menu.

The showcase/landing page remains the default browser view. An /about page summarizes the game, current build version, host, author, privacy stance, source repository, and optional sponsorship link.

Cold PWA/game starts now begin with a skippable preroll. The author logo fades in from black, the Time Pilot logo and player flyby play next, then the logo animates into the root-menu position while the attract demo starts behind it.

If a real player run is interrupted by closing or backgrounding the page, the game stores a small session snapshot during page lifecycle events. On the next launch it skips the preroll, restores the run into the same era, and opens the root menu paused on Continue. A Restart action appears directly beneath it for players who want to discard the restored run and begin again. The snapshot is not written every frame, and demo, preroll, game-over, and time-warp states are ignored.

🧪 Quality Checks

Run the same checks used by CI:

npm test
npm run lint
npm run typecheck
npm run build

npm test runs the Vitest suite in jsdom.

The test suite covers:

  • Engine helpers, collision checks, object cloning, headings, and rotation math.
  • Canvas arena, ticker, and sound wrappers using browser API shims.
  • Keyboard, gamepad, mouse, and touch controller adapters.
  • Menu definitions and state callbacks.
  • Game entities, factories, HUD wiring, and context-backed modules.
  • The TimePilot orchestrator and React TimePilotGame host component.

⚛️ React Usage

Use the game as a regular React component:

import TimePilotGame from "./components/TimePilotGame";

function App() {
  return <TimePilotGame debug />;
}

The component delegates engine lifecycle to the hook:

const { setContainerElement, pause, resume, restart, destroy } =
  useTimePilot({ debug: true });

React owns mounting and cleanup. The game engine owns simulation, rendering, input, and timing.

TimePilotGame includes controller settings and input handling for:

  • Directional keyboard: arrow keys or WASD point directly up, right, down, or left.
  • Rotate keyboard: left/right rotate the player around the current heading.
  • Gamepad: enables or disables browser Gamepad API polling.
  • Touch: steering is relative to where the thumb first touches the screen, firing happens while touching, two-finger taps open the menu, three-finger taps request restart, and pinch gestures adjust UI and game zoom together. On touch-capable devices, the Touch Steering Guide is enabled by default and can be disabled from Options. During gameplay it draws a guide from the initial touch point to the player thumb's current fire button position, and it disappears as soon as that touch is released.

The game also renders its start and options menus inside the canvas. Keyboard and gamepad commands move, adjust, and activate menu items through the same controller interface used for gameplay. Mouse and touch input support pointer selection, scroll wheel or drag scrolling on overflowing menus, and scrollbar dragging. Options currently include volume levels, fullscreen, controls overlay, UI zoom, game POV zoom, video filters, achievements, language, and custom keyboard bindings. UI zoom can also be adjusted from the keyboard with +/= and -, and reset with 0. Both zoom options default to 100% and range from 25% to 250% in 5% steps.

During play, P pauses the game and Escape opens the root menu with a Paused subtitle and a Continue action. Pressing Escape again from that paused root menu resumes play, matching the Continue button. In submenus, Escape, Backspace, and the gamepad back button return to the previous menu. M and the gamepad menu button jump back to the root menu.

The root menu can also enter a watchable gameplay demo. In demo view, the menu drops away, the logo animates to the submenu position, HUD elements remain visible, and any player input returns to the root menu. The demo player is not invincible: it can die, auto-continue, score points, dodge threats, shoot enemies/projectiles/bosses, and collect parachute bonuses.

The root menu also includes an achievements page. It lays out achievement cards responsively, shows locked or unlocked sprite frames where icons exist, and renders persistent counter progress for achievements such as Quarter Master. Unlock notifications slide in above the credits line during play.

Storybook includes a sprite gallery that renders the real sprite sheets with a live animation preview beside the highlighted source frames. The gallery can switch between animation, direction, damage, death-flash, state, and static views for sheets that support those rows.

Game over now uses a canvas dialogue. If continues remain, the primary action is Continue; otherwise it becomes Restart. Exit returns to the root menu.

When debug mode is unlocked, the level select menu includes translated era blurbs on the left, level buttons in the centre, and animated enemy, special, boss, and bonus previews on the right. Focusing a level also pins the background demo preview to that era until the level select screen is closed. Debug overlays can also show hitboxes, heading and steering vectors, and an optional turn-arc fill for intentional moving entities. The debug menu can also replay the startup preroll, change the runtime log level, and open reset tools for stored preferences, scores, achievements, or all Time Pilot data. Destructive reset actions require confirmation and name the exact data group being cleared.

Runtime logging is disabled by default. When enabled from the debug menu, the logger supports debug, info, warning, error, and fatal thresholds and uses structured console details for lifecycle events such as preroll, game start, continues, resets, achievements, game over, and time warp.

Gameplay now includes score-based extra lives at 10,000 points and every 50,000 points after, compact HUD life counts once they reach nine lives, continues, era-specific projectile tuning, homing rockets for levels 3 and 4, level 5 plasma shots, shootable rockets/bombs/plasma, refreshed cloud and asteroid props, and the six-second time-warp transition between eras. Entity sounds support spatial panning where the browser allows it.

🧭 Project Structure

src/
  components/
    TimePilotGame.tsx       React host component
  game/
    index.ts                TimePilot engine orchestrator
    use-time-pilot.ts       React bridge hook
    types.ts                Shared game contracts
    constants.ts            Game tuning and asset constants
    game-timing.ts          Shared simulation tick rate
    logger.ts               Debug-menu-controlled runtime logger
    screen-wake-lock.ts     Installed-PWA keep-awake helper
    storage-reset.ts        Debug reset helpers for persisted data
    achievements.ts         Achievement definitions and tracking subsystem
    achievement-notifications.ts
                            Canvas unlock popup renderer
    preroll.ts              Startup author logo, flyby, and menu handoff
    __tests__/              Game module test coverage
    controller/             Keyboard and gamepad input adapters
    engine/                 Canvas arena, ticker, sound, helpers
    menus/                  Menu definitions
    systems/                Collision, spawning, and rendering systems
    ui-scale.ts             UI and game zoom helpers
    time-warp.ts            Player time-warp sequence timing
    *.ts                    Entities, factories, HUD, options
  test/
    setup.ts                Vitest jsdom/browser API shims

🧠 Architecture

The current design keeps React out of the game loop. This is deliberate.

  • React handles lifecycle, layout, and app composition.
  • useTimePilot creates and destroys the game instance.
  • TimePilot owns the game context and wires systems together.
  • Entities, factories, controllers, HUD, and engine wrappers are class-based modules.
  • Collision, spawning, and frame rendering live in dedicated systems under src/game/systems.
  • Entities and factories receive explicit context instead of reading a global singleton.
  • Simulation uses a fixed-step 50Hz ticker for movement, spawning, collisions, cleanup, and player actions.
  • Rendering uses a separate FPS-capped animation-frame ticker to paint the latest entity locations and orientations without changing gameplay speed.
  • Game rendering applies pixelated POV scaling separately from HUD and menu UI scaling.
  • Rendering stays canvas-based for predictable paint ordering and frame-by-frame control.
  • Public game utilities, engine entry points, systems, controllers, and React bridge components include JSDoc comments for generated API documentation and easier maintenance.

🔁 CI/CD

GitHub Actions are configured for:

  • Run Tests on pull requests to main.
  • Run Lint on pull requests to main.
  • Run TypeCheck on pull requests to main.
  • Build on pull requests to main.
  • Deploy to GitHub Pages on pushes to main.
  • Release Current Version on pushes to main.

GitHub Pages builds with:

VITE_BASE_PATH=/time-pilot/ npm run build

🔢 Versioning

The repo includes a pre-commit hook at .githooks/pre-commit.

Enable it in a fresh checkout:

git config core.hooksPath .githooks

The hook runs:

npm run precommit:staged
npm run version:bump

precommit:staged checks a temporary checkout of the staged index. It type-checks staged TypeScript files from that staged snapshot and lints only staged lintable files, without stashing or inspecting unstaged work. Pull request checks still run full project scans.

The version bump script inspects staged changes:

  • major for public API removals, deletions, or explicit breaking-change signals.
  • minor for new source, assets, dependency changes, exports, or game feature code.
  • patch for smaller implementation/config fixes.
  • none for docs-only changes.

Override the heuristic when needed:

TIME_PILOT_VERSION_BUMP=major git commit
TIME_PILOT_VERSION_BUMP=minor git commit
TIME_PILOT_VERSION_BUMP=patch git commit
TIME_PILOT_VERSION_BUMP=none git commit

🗒️ Milestones

See WHATSNEW.md for major project milestones and migration history.

📄 Licence

See LICENSE.md. In short: play it, learn from it, and tinker with it privately; do not publish, host, distribute, or re-release it without permission.

About

A recreation in JavaScript of the 1982 classic arcade shooter.

Resources

License

Stars

Watchers

Forks

Sponsor this project

  •  

Packages

 
 
 

Contributors