Files
the-last-startyper/UNIFIED_API.md
T

23 KiB
Raw Blame History

Unified API Specification

Version: 2.0
Last Updated: 2025-01-22

Complete specification for building VIMazing games. This document defines the architecture, requirements, and patterns that all games must follow to maintain consistency while allowing game-specific customization.


Table of Contents

  1. Philosophy
  2. Architecture Overview
  3. Core Requirements
  4. Game-Specific Flexibility
  5. Hook Specifications
  6. Type System
  7. Game Instructions Export
  8. Creating a New Game
  9. Integration Guide

Philosophy

The Unified API is designed around three core principles:

1. Consistent Entry Point

Every game uses useGame(options?, platformHook?) as its single entry point. This ensures:

  • Predictable initialization across all games
  • Easy platform integration
  • Consistent developer experience

2. Composable Architecture

Games are built from independent, composable hooks that manage distinct concerns:

  • useBoard - Level/puzzle generation and rendering
  • useCursor - Player input and interaction
  • useScore - Metrics and performance tracking
  • useGameStatus - Game lifecycle state machine

3. Freedom Within Constraints

The API defines WHAT must be exposed, not HOW it works internally. Games have complete freedom in:

  • Scoring algorithms
  • Rendering strategies
  • Internal state management
  • Additional game-specific features

Architecture Overview

The Unified Pattern

useGame(options, platformHook?)
  ├─ useBoard()          → Game-specific board/level management
  ├─ useCursor()         → Player input and interaction
  ├─ useGameStatus()     → Lifecycle state machine
  └─ useScore()          → Metrics and scoring (game-specific)

Data Flow

User Input
  ↓
useCursor (validates, processes)
  ↓
useBoard (updates state)
  ↓
useScore (tracks metrics)
  ↓
useGameStatus (manages lifecycle)
  ↓
GameManager (unified interface)
  ↓
Platform/UI

Core Requirements

These elements MUST be present in every VIMazing game:

1. Entry Point

export function useGame(
  options: GameOptions,
  platformHook?: unknown
): GameManager

Required:

  • Accept options object (game-specific structure)
  • Support optional platform hook function
  • Return GameManager interface

Example:

// vim-sudoku
useGame({ difficulty: 'easy', timeLimit: 600 })

// vim-maze
useGame({ rows: 24, cols: 32, timeLimit: 600 })

2. GameManager Interface

Every game must return a GameManager with these minimum fields:

type GameManager = {
  // Required Core
  containerRef: RefObject<HTMLDivElement | null>;  // DOM mounting point
  renderBoard: () => void;                          // Force re-render
  
  // Required State Management
  gameStatus: GameStatus;
  setGameStatus: (status: GameStatus) => void;
  startGame: () => void;
  quitGame: () => void;
  
  // Required Managers
  cursor: CursorManager;      // Player interaction
  scoreManager: ScoreManager; // Metrics and scoring
  
  // Required Input Tracking
  keyLog: KeyLogEntry[];
  clearKeyLog: () => void;
  getKeyLog: () => KeyLogEntry[];
  
  // Optional: Game-specific additions
  changeGameMode?: (options: GameOptions) => void;  // Runtime option changes
  // [additional fields as needed]
};

3. Game Status State Machine

All games must implement this lifecycle:

waiting → started → game-won
              ↓
           game-over

Required States:

  • waiting - Initial state, game not started
  • started - Game in progress
  • game-won - Successfully completed
  • game-over - Failed to complete

Optional States:

  • paused - Temporarily stopped
  • Game-specific states (e.g., vim-maze's has-key)

4. Cursor/Input Manager

type CursorManager = {
  position: () => Coord;        // Current position
  mode: () => CursorMode;       // Current mode
  
  // Movement (VIM-style)
  moveLeft: (count?: number) => void;
  moveRight: (count?: number) => void;
  moveUp: (count?: number) => void;
  moveDown: (count?: number) => void;
  
  // Anchors (VIM-style)
  moveToStart: () => void;      // ^ or 0
  moveToEnd: () => void;        // $
  moveToTop: () => void;        // gg
  moveToBottom: () => void;     // G
  
  // Repeat (VIM-style)
  repeatLastMotion: () => void; // .
  
  // Optional: Game-specific methods
  // [additional methods as needed]
};

5. Score Manager (Minimal Interface)

type ScoreManager = {
  // Required Timing
  timeValue: number;            // Current time in milliseconds
  
  // Required Metrics
  totalKeystrokes: number;      // Total keys pressed
  
  // Required Result
  finalScore: number | null;    // 0-1000 (null until game-won)
  
  // Optional: Game-specific metrics
  // [additional fields as needed]
};

the-last-startyper Content Tracking:

All content types are tracked independently throughout the game:

type ScoreManager = {
  // ... core fields
  
  lettersCompleted: number;      // Total letters typed
  wordsCompleted: number;        // Total words typed
  sentencesCompleted: number;    // Total sentences typed
  paragraphsCompleted: number;   // Total paragraphs typed
  currentCount: number;          // Count for current game mode
};

Platform Mode Switching Behavior:

When platform calls changeGameMode():

  • All historical counts remain (letters stay at 10 after switching to words)
  • currentCount reflects the mode-specific count (0 for words if no words typed yet)
  • Each mode accumulates independently

Scoring Requirements:

  • Score range: 0-1000 (capped)
  • Score calculated on game-won
  • Based on time and keystrokes (minimum)
  • Can include game-specific penalties/bonuses

Runtime Game Control

Some games support dynamic option changes during gameplay via changeGameMode():

// Change to a different mode mid-game
game.changeGameMode({ gameMode: 'words', downwardSpeed: 75 });

Delayed Mode Switching (the-last-startyper)

Mode changes are deferred to respect current gameplay:

  • Changes are stored as "pending options"
  • Applied AFTER current letter/word/sentence/paragraph completes
  • OR when player dies (text hits ship)
  • Multiple rapid calls overwrite with latest options
  • Ensures smooth transitions without interrupting input

Flow:

Platform calls changeGameMode()
   ↓
Options stored as pending
   ↓
Player finishes current letter/word
   ↓
Pending options applied
   ↓
Next letter spawns with new settings

Game-Specific Flexibility

These elements CAN vary between games:

1. GameOptions Structure

Each game defines its own options:

// vim-sudoku
type GameOptions = {
  difficulty?: 'easy' | 'medium' | 'hard';
  timeLimit?: number;
  removedCells?: number;
};

// vim-maze
type GameOptions = {
  rows: number;
  cols: number;
  timeLimit?: number;
};

// the-last-startyper (Unified Platform Control)
type GameOptions = {
  gameMode?: 'letters' | 'words' | 'sentences' | 'paragraphs';
  downwardSpeed?: number;           // px/s (mode-agnostic)
  boardDimensions?: [number, number]; // [width, height]
  wordList?: string[];              // Custom word/sentence/paragraph list
};

// vim-snake (hypothetical)
type GameOptions = {
  boardSize?: number;
  speed?: 'slow' | 'medium' | 'fast';
  obstacles?: boolean;
};

Recommended Defaults:

  • Always provide sensible defaults
  • Make most options optional
  • Use consistent naming (e.g., timeLimit always in seconds)

Platform Control (the-last-startyper):

  • Platforms have full control over game behavior via GameOptions
  • downwardSpeed is mode-agnostic (single value for all modes)
  • wordList allows custom content across all modes
  • changeGameMode(options) allows runtime mode switching
  • Mode changes respect current gameplay (apply after letter/word completes or death)

2. Scoring Implementation

Complete freedom in how scoring works internally:

vim-sudoku approach:

// Simple formula: time + keystrokes + hints
const baseScore = 1000 - timePenalty - keystrokePenalty - hintPenalty;
const finalScore = min(1000, baseScore * difficultyMultiplier);

vim-maze approach:

// Size-based: time + keystrokes × maze size
const baseScore = 1000 - timePenalty - keystrokePenalty;
const sizeMultiplier = max(1.0, (rows × cols) / 500);
const finalScore = min(1000, baseScore × sizeMultiplier);

vim-snake approach (hypothetical):

// Performance-based: length achieved vs time
const lengthScore = (snakeLength / maxLength) × 500;
const timeBonus = (timeRemaining / timeLimit) × 500;
const finalScore = min(1000, lengthScore + timeBonus);

3. Rendering Strategy

Games choose their own rendering approach:

DOM Rendering (vim-maze, vim-sudoku):

class GameRenderer {
  constructor(container: HTMLElement) { }
  render(data: GameData): void {
    // Direct DOM manipulation
    container.innerHTML = generateHTML(data);
  }
}

React Rendering (hypothetical):

function GameBoard({ boardState }: Props) {
  return (
    <div>
      {boardState.map(cell => <Cell key={cell.id} {...cell} />)}
    </div>
  );
}

Canvas Rendering (hypothetical):

class CanvasRenderer {
  render(ctx: CanvasRenderingContext2D, gameState: State): void {
    // Direct canvas drawing
  }
}

4. Internal State Management

Games manage state however they need:

vim-sudoku:

  • Board as 81-character string
  • Initial board (immutable)
  • Solution board (for validation)
  • No complex animation state

vim-maze:

  • 2D array of maze cells
  • Hero position tracking
  • Animation state for multi-step movements
  • Distance calculations for pathfinding

vim-snake (hypothetical):

  • Snake segments array
  • Food position
  • Direction queue
  • Collision detection

5. Additional Features

Games can add unique features:

vim-sudoku specific:

  • Hint system (Shift+H)
  • Edit modes (i/r/c for different entry styles)
  • Cell validation (given vs user-entered)

vim-maze specific:

  • Key pickup mechanic
  • Wall collision detection
  • Entrance/exit system
  • Distance tracking

vim-snake specific (hypothetical):

  • Power-ups
  • Speed changes
  • Obstacle generation
  • Multiplayer

Hook Specifications

useBoard

Purpose: Manage game board/level state and rendering.

Flexibility: Complete freedom in implementation.

Minimum Interface:

type BoardManager = {
  containerRef: RefObject<HTMLDivElement | null>;
  renderBoard: () => void;
  
  // Game-specific additions allowed
};

Common Patterns:

// Pattern 1: Generator + Renderer classes
const generator = new GameGenerator(options);
const renderer = new GameRenderer(container);
generator.generate();
renderer.render(generator.getData());

// Pattern 2: Direct state management
const [board, setBoard] = useState(generateInitial());
renderBoard(board);

// Pattern 3: Canvas-based
const canvasRef = useRef<HTMLCanvasElement>(null);
drawBoard(canvasRef.current, boardState);

useCursor

Purpose: Handle player input and interaction.

Required: VIM-style navigation (hjkl, counts, anchors, repeat).

Minimum Interface:

type CursorManager = {
  position: () => Coord;
  mode: () => CursorMode;
  
  // VIM motions (required)
  moveLeft: (count?: number) => void;
  moveRight: (count?: number) => void;
  moveUp: (count?: number) => void;
  moveDown: (count?: number) => void;
  moveToStart: () => void;
  moveToEnd: () => void;
  moveToTop: () => void;
  moveToBottom: () => void;
  repeatLastMotion: () => void;
  
  // Game-specific additions allowed
};

Mode System: All games use at minimum:

  • normal - Navigation mode
  • edit - Interaction mode (if applicable)

Games can add modes as needed (e.g., sudoku's distinction between i/r/c entry modes).

useScore

Purpose: Track metrics and calculate final score.

Flexibility: Almost complete freedom.

Minimum Interface:

type ScoreManager = {
  timeValue: number;
  totalKeystrokes: number;
  finalScore: number | null;
  
  // Game-specific metrics allowed
};

Requirements:

  • Track time in milliseconds
  • Track total keystrokes
  • Calculate score 0-1000 (capped)
  • Set finalScore only on game-won

Optional but Recommended:

  • Game-over conditions based on time/performance
  • Additional metrics for display
  • Difficulty multipliers

the-last-startyper Content Tracking:

  • Tracks all content types independently (letters, words, sentences, paragraphs)
  • Preserves historical counts when mode switches
  • currentCount reflects only the current game mode count

useGameStatus

Purpose: Manage game lifecycle state machine.

Required States:

type GameStatus = 
  | 'waiting'
  | 'started'
  | 'game-won'
  | 'game-over'
  | ...  // Optional game-specific states

Minimum Interface:

type GameStatusManager = {
  gameStatus: GameStatus;
  setGameStatus: (status: GameStatus) => void;
  startGame: () => void;
  quitGame: () => void;
  
  // Optional: togglePause, reset, etc.
  // Optional: setPendingOptions (for runtime changes)
  setPendingOptions?: (options: GameOptions) => void;
};

Type System

Required Core Types

// Coordinates (all games use this)
type Coord = { row: number; col: number };

// Key logging (all games track this)
type KeyLogEntry = { key: string; timestamp: number };

// Game status (required states)
type GameStatus = 'waiting' | 'started' | 'game-won' | 'game-over' | ...;

// Cursor mode (at minimum)
type CursorMode = 'normal' | 'edit' | ...;

Type Organization

Recommended structure:

src/
├── types.ts              # Game-specific exports + core types
├── useBoard/
│   └── types.ts          # Board-specific types
├── useCursor/
│   └── types.ts          # Cursor-specific types
├── useGameStatus/
│   └── types.ts          # Status-specific types
└── useScore/
    └── types.ts          # Score-specific types

Main types.ts pattern:

// Re-export all module types
export * from './useBoard/types';
export * from './useCursor/types';
export * from './useGameStatus/types';
export * from './useScore/types';

// Game-specific types
export type GameOptions = { /* game-specific */ };
export type GameManager = { /* unified interface */ };

Game Instructions Export

Required Export

All games must export a gameInfo object containing structured documentation for platform consumption.

File: src/gameInfo.ts

export const gameInfo = {
  name: string;                    // Game name
  description: string;             // Brief description
  
  controls: {
    navigation: Array<{ keys: string; description: string }>;
    // Additional control sections as needed
    game: Array<{ keys: string; description: string }>;
  };
  
  rules: {
    // Game-specific rule sections
  };
  
  scoring: {
    formula: string;
    range: string;
    penalties: Array<{ factor: string; formula: string; example: string }>;
    examples: Array<any>;
  };
  
  gameOver: {
    conditions: Array<{ type: string; trigger: string; message: string }>;
  };
  
  objective: string;               // How to win (one sentence)
  winCondition: string;            // Specific win trigger
} as const;

export type GameInfo = typeof gameInfo;

Export from main index:

// src/index.ts
export { gameInfo } from './gameInfo';
export type { GameInfo } from './gameInfo';

Purpose

The gameInfo export allows platforms to:

  • Render in-game help screens
  • Generate tutorials
  • Display control references
  • Show scoring breakdowns
  • Explain game mechanics

Without needing to parse documentation or README files.

Usage by Platforms

import { gameInfo } from '@vimazing/your-game';

// Render controls
gameInfo.controls.navigation.forEach(control => {
  renderControl(control.keys, control.description);
});

// Show scoring formula
displayScoringInfo(gameInfo.scoring.formula, gameInfo.scoring.penalties);

// Display game-over conditions
gameInfo.gameOver.conditions.forEach(condition => {
  showCondition(condition.type, condition.trigger);
});

Required Sections

Minimum structure:

  • name - Game name
  • description - Brief description
  • controls.navigation - VIM motion controls (required)
  • controls.game - Game lifecycle controls (space, q, p)
  • rules - At least one rule section
  • scoring.formula - Score calculation
  • scoring.range - Score bounds (always "0 - 1000 points")
  • gameOver.conditions - At least one condition
  • objective - Win goal
  • winCondition - Specific trigger

Optional sections:

  • Additional control groups (editing, actions, etc.)
  • Visual feedback explanations
  • Hints/power-ups/special mechanics
  • Examples and breakdowns

Examples

See implementations:

  • vim-sudoku/src/gameInfo.ts - Complex puzzle game with modes, hints
  • vim-maze/src/gameInfo.ts - Navigation game with maze mechanics
  • vimazing-game-skeleton/src/gameInfo.ts - Template with TODOs

Creating a New Game

Step 1: Define Your Game

Answer these questions:

  • What is the game mechanic?
  • What does the board/level look like?
  • How does the player interact?
  • What constitutes winning/losing?
  • How should scoring work?

Step 2: Design GameOptions

export type GameOptions = {
  // Required configuration
  [key: string]: any;
  
  // Recommended: timeLimit
  timeLimit?: number;  // Seconds
};

Step 3: Implement Core Hooks

useBoard:

export function useBoard(/* game-specific params */) {
  const containerRef = useRef<HTMLDivElement>(null);
  
  function renderBoard() {
    // Your rendering logic
  }
  
  return {
    containerRef,
    renderBoard,
    // Additional methods
  };
}

useCursor:

export function useCursor(/* dependencies */) {
  const [position, setPosition] = useState<Coord>({ row: 0, col: 0 });
  const [mode, setMode] = useState<CursorMode>('normal');
  
  // Implement VIM motions
  function moveLeft(count = 1) { /* ... */ }
  function moveRight(count = 1) { /* ... */ }
  // ... etc
  
  return {
    position: () => position,
    mode: () => mode,
    moveLeft,
    moveRight,
    // ... etc
  };
}

useScore:

export function useScore(/* dependencies */) {
  const [timeValue, setTimeValue] = useState(0);
  const [finalScore, setFinalScore] = useState<number | null>(null);
  const totalKeystrokes = keyLog.length;
  
  // Calculate score on game-won
  useEffect(() => {
    if (gameStatus === 'game-won') {
      const score = calculateScore(timeValue, totalKeystrokes);
      setFinalScore(Math.min(1000, Math.max(0, score)));
    }
  }, [gameStatus]);
  
  return {
    timeValue,
    totalKeystrokes,
    finalScore,
    // Additional metrics
  };
}

useGameStatus:

export function useGameStatus() {
  const [gameStatus, setGameStatus] = useState<GameStatus>('waiting');
  
  function startGame() {
    setGameStatus('started');
  }
  
  function quitGame() {
    setGameStatus('waiting');
  }
  
  return {
    gameStatus,
    setGameStatus,
    startGame,
    quitGame,
  };
}

Step 4: Compose in useGame

export function useGame(
  options: GameOptions,
  platformHook?: unknown
): GameManager {
  const board = useBoard(/* options */);
  const gameStatusManager = useGameStatus();
  const cursor = useCursor(/* dependencies */);
  const scoreManager = useScore(/* dependencies */);
  
  const gameManager: GameManager = {
    containerRef: board.containerRef,
    renderBoard: board.renderBoard,
    gameStatus: gameStatusManager.gameStatus,
    setGameStatus: gameStatusManager.setGameStatus,
    startGame: gameStatusManager.startGame,
    quitGame: gameStatusManager.quitGame,
    cursor,
    scoreManager,
    keyLog: [],  // Implement key tracking
    clearKeyLog: () => {},
    getKeyLog: () => [],
  };
  
  if (typeof platformHook === 'function') {
    platformHook(gameManager);
  }
  
  return gameManager;
}

Step 5: Export Public API

// src/index.ts
export { useGame } from './useGame';
export type { GameManager, GameOptions } from './types';
export type { CursorManager } from './useCursor/types';
export type { ScoreManager } from './useScore/types';

Integration Guide

For Game Consumers

Basic Usage:

import { useGame } from '@vimazing/your-game';
import '@vimazing/your-game/game.css';

function GameComponent() {
  const game = useGame(options);
  
  return (
    <div>
      <div ref={game.containerRef} />
      <button onClick={game.startGame}>Start</button>
      <p>Status: {game.gameStatus}</p>
      <p>Score: {game.scoreManager.finalScore || 0} / 1000</p>
    </div>
  );
}

With Platform Hook:

function platformHook(gameManager: GameManager) {
  // Analytics
  trackGameStart(gameManager.gameStatus);
  
  // Custom keybindings
  window.addEventListener('keydown', (e) => {
    if (e.key === 'h') trackHelp();
  });
  
  // Monitor completion
  const interval = setInterval(() => {
    if (gameManager.gameStatus === 'game-won') {
      trackVictory(gameManager.scoreManager.finalScore);
      clearInterval(interval);
    }
  }, 100);
}

const game = useGame(options, platformHook);

For Platform Developers

All games expose the same core interface:

interface UnifiedGame {
  useGame(options: any, platformHook?: Function): GameManager;
}

Integration checklist:

  • ✅ Game exposes useGame entry point
  • ✅ Returns GameManager with required fields
  • ✅ Accepts optional platform hook
  • ✅ Implements core game status states
  • ✅ Provides VIM-style cursor navigation
  • ✅ Scores on 0-1000 scale

Version History

v2.1 (2025-11-04)

  • Added delayed mode switching for runtime game control
  • Documented changeGameMode() for platforms
  • Added GameOptions for platform control (gameMode, downwardSpeed, wordList)
  • Clarified pending options pattern for smooth transitions
  • Updated GameManager interface with optional runtime control methods
  • Added content tracking per mode (letters, words, sentences, paragraphs)
  • currentCount reflects mode-specific completion count
  • Historical counts preserved across mode switches

v2.0 (2025-01-22)

  • Rewritten for game-agnostic specification
  • Clarified requirements vs flexibility
  • Added comprehensive creation guide
  • Removed game-specific implementation details
  • Simplified scoring requirements

v1.0 (2024)

  • Initial unified API documentation
  • Based on vim-maze implementation

License

MIT © VIMazing Project