Files
dflike/docs/plans/2026-03-06-multiplayer-game-implementation.md
T
andy b94ad32806 feat: viewport modes, configurable server URL, deployment docs
- Add follow mode (F key) to cycle camera between NPCs
- Make server URL configurable via VITE_SERVER_URL env var
- Add comprehensive README with Debian LXC deployment guide
- Add character sprite assets to repo
- Add design docs and implementation plans

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 22:25:53 -05:00

63 KiB

Multiplayer NPC Simulation - Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Build a working multiplayer web game with server-authoritative NPC simulation, composited character sprites, and player observer/avatar modes.

Architecture: Server-authoritative ECS simulation (Node.js + Socket.io) with dumb-renderer clients (Phaser 3 + TypeScript). Monorepo with npm workspaces. Custom lightweight ECS. NPCs have needs-driven AI. Characters are composited from layered sprite assets at 48x48 per frame.

Tech Stack: Phaser 3, TypeScript, Socket.io, Vite, Node.js, npm workspaces

Design doc: docs/plans/2026-03-06-multiplayer-game-design.md


Task 1: Project Scaffolding

Files:

  • Create: package.json (root workspace)
  • Create: shared/package.json
  • Create: shared/tsconfig.json
  • Create: server/package.json
  • Create: server/tsconfig.json
  • Create: client/package.json
  • Create: client/tsconfig.json
  • Create: client/vite.config.ts
  • Create: client/index.html

Step 1: Initialize git repo

cd D:/gamedev/dflike
git init

Step 2: Create root package.json with workspaces

{
  "name": "dflike",
  "private": true,
  "workspaces": ["shared", "server", "client"]
}

Step 3: Create shared package

shared/package.json:

{
  "name": "@dflike/shared",
  "version": "0.1.0",
  "private": true,
  "main": "src/index.ts",
  "types": "src/index.ts"
}

shared/tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "declaration": true,
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src"]
}

Step 4: Create server package

server/package.json:

{
  "name": "@dflike/server",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/main.ts",
    "start": "tsx src/main.ts",
    "test": "vitest run",
    "test:watch": "vitest"
  },
  "dependencies": {
    "@dflike/shared": "*",
    "socket.io": "^4.7.0"
  },
  "devDependencies": {
    "tsx": "^4.7.0",
    "typescript": "^5.4.0",
    "vitest": "^2.0.0"
  }
}

server/tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "outDir": "dist",
    "rootDir": "src",
    "paths": {
      "@dflike/shared": ["../shared/src"]
    }
  },
  "include": ["src"],
  "references": [{ "path": "../shared" }]
}

Step 5: Create client package

client/package.json:

{
  "name": "@dflike/client",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build"
  },
  "dependencies": {
    "@dflike/shared": "*",
    "phaser": "^3.80.0",
    "socket.io-client": "^4.7.0"
  },
  "devDependencies": {
    "typescript": "^5.4.0",
    "vite": "^5.4.0"
  }
}

client/tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "outDir": "dist",
    "rootDir": "src",
    "paths": {
      "@dflike/shared": ["../shared/src"]
    }
  },
  "include": ["src"],
  "references": [{ "path": "../shared" }]
}

client/vite.config.ts:

import { defineConfig } from 'vite';
import path from 'path';

export default defineConfig({
  resolve: {
    alias: {
      '@dflike/shared': path.resolve(__dirname, '../shared/src'),
    },
  },
  server: {
    port: 3000,
  },
});

client/index.html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>DFlike</title>
  <style>
    * { margin: 0; padding: 0; }
    body { background: #000; overflow: hidden; }
  </style>
</head>
<body>
  <div id="game"></div>
  <script type="module" src="/src/main.ts"></script>
</body>
</html>

Step 6: Create .gitignore

node_modules/
dist/
.vite/

Step 7: Install dependencies

npm install

Step 8: Verify all packages installed

npm ls --depth=0

Step 9: Commit

git add package.json shared/ server/package.json server/tsconfig.json client/package.json client/tsconfig.json client/vite.config.ts client/index.html .gitignore
git commit -m "feat: scaffold monorepo with shared, server, and client packages"

Task 2: Shared Types and Constants

Files:

  • Create: shared/src/index.ts
  • Create: shared/src/types.ts
  • Create: shared/src/constants.ts

Step 1: Define constants

shared/src/constants.ts:

export const TILE_SIZE = 48;
export const WORLD_WIDTH = 64;
export const WORLD_HEIGHT = 64;
export const TICK_RATE = 10; // server ticks per second
export const BROADCAST_EVERY_N_TICKS = 3; // state broadcast frequency
export const SPRITE_FRAME_WIDTH = 48;
export const SPRITE_FRAME_HEIGHT = 48;
export const SPRITE_COLS = 6;
export const SPRITE_ROWS = 4;

// NPC needs
export const HUNGER_DECAY_PER_TICK = 0.05;
export const ENERGY_DECAY_PER_TICK = 0.03;
export const HUNGER_THRESHOLD = 30;
export const ENERGY_THRESHOLD = 20;
export const NEED_RECOVERY_RATE = 0.5;

// Directions (row index in spritesheet)
export const Direction = {
  DOWN: 0,
  LEFT: 1,
  RIGHT: 2,
  UP: 3,
} as const;
export type Direction = (typeof Direction)[keyof typeof Direction];

// Accessory slot names in z-order (bottom to top rendering)
export const ACCESSORY_SLOTS = [
  'bottom', 'feet', 'chest', 'arm', 'shoulder',
  'waist', 'back', 'facialHair', 'haircut', 'hat',
] as const;
export type AccessorySlot = (typeof ACCESSORY_SLOTS)[number];

Step 2: Define types

shared/src/types.ts:

import type { AccessorySlot } from './constants.js';

// Entity is just a numeric ID
export type EntityId = number;

// Components
export interface Position {
  x: number;
  y: number;
}

export interface Velocity {
  dx: number;
  dy: number;
}

export interface Appearance {
  skinId: string; // e.g. "shape00_skin02"
  accessories: Partial<Record<AccessorySlot, string>>; // slot -> filename without extension
}

export interface Needs {
  hunger: number; // 0-100
  energy: number; // 0-100
}

export type MovementState = 'idle' | 'walking';
export type GoalType = 'wander' | 'eat' | 'rest';

export interface Movement {
  state: MovementState;
  target: Position | null;
  path: Position[];
  direction: number; // Direction enum value
}

export interface PlayerControlled {
  playerId: string;
  mode: 'avatar' | 'camera';
}

export interface NPCBrain {
  currentGoal: GoalType | null;
  goalQueue: GoalType[];
}

// Network protocol messages
export interface EntityState {
  id: EntityId;
  position: Position;
  movement: Movement;
  appearance: Appearance;
  needs?: Needs;
  npcBrain?: NPCBrain;
  playerControlled?: PlayerControlled;
}

export interface WorldState {
  entities: EntityState[];
  worldWidth: number;
  worldHeight: number;
  tileSize: number;
  obstacles: Position[];
  pointsOfInterest: { type: 'food' | 'rest'; position: Position }[];
}

export interface StateUpdate {
  entities: EntityState[];
  tick: number;
}

export interface PlayerJoined {
  playerId: string;
  entityId: EntityId;
}

export interface PlayerLeft {
  playerId: string;
}

export interface PlayerInput {
  type: 'move' | 'toggle-mode' | 'follow' | 'interact';
  direction?: { dx: number; dy: number };
  targetPlayerId?: string;
  targetEntityId?: EntityId;
}

// Server -> Client events
export interface ServerEvents {
  'world-state': (data: WorldState) => void;
  'state-update': (data: StateUpdate) => void;
  'player-joined': (data: PlayerJoined) => void;
  'player-left': (data: PlayerLeft) => void;
  'npc-recomposed': (data: { entityId: EntityId; appearance: Appearance }) => void;
}

// Client -> Server events
export interface ClientEvents {
  'player-input': (data: PlayerInput) => void;
}

Step 3: Create barrel export

shared/src/index.ts:

export * from './constants.js';
export * from './types.js';

Step 4: Verify TypeScript compiles

cd D:/gamedev/dflike && npx -w shared tsc --noEmit

Step 5: Commit

git add shared/src/
git commit -m "feat: add shared types and constants for ECS, networking, and assets"

Task 3: ECS Core

Files:

  • Create: server/src/ecs/World.ts
  • Create: server/src/ecs/__tests__/World.test.ts

Step 1: Write failing tests for ECS World

server/src/ecs/__tests__/World.test.ts:

import { describe, it, expect } from 'vitest';
import { World } from '../World.js';

describe('World', () => {
  it('creates entities with unique IDs', () => {
    const world = new World();
    const e1 = world.createEntity();
    const e2 = world.createEntity();
    expect(e1).not.toBe(e2);
  });

  it('adds and retrieves components', () => {
    const world = new World();
    const entity = world.createEntity();
    world.addComponent(entity, 'position', { x: 10, y: 20 });
    expect(world.getComponent(entity, 'position')).toEqual({ x: 10, y: 20 });
  });

  it('returns undefined for missing components', () => {
    const world = new World();
    const entity = world.createEntity();
    expect(world.getComponent(entity, 'position')).toBeUndefined();
  });

  it('queries entities with specific components', () => {
    const world = new World();
    const e1 = world.createEntity();
    const e2 = world.createEntity();
    const e3 = world.createEntity();
    world.addComponent(e1, 'position', { x: 0, y: 0 });
    world.addComponent(e1, 'needs', { hunger: 100, energy: 100 });
    world.addComponent(e2, 'position', { x: 5, y: 5 });
    world.addComponent(e3, 'needs', { hunger: 50, energy: 50 });

    const withBoth = world.query('position', 'needs');
    expect(withBoth).toEqual([e1]);

    const withPosition = world.query('position');
    expect(withPosition.sort()).toEqual([e1, e2].sort());
  });

  it('removes entities and their components', () => {
    const world = new World();
    const entity = world.createEntity();
    world.addComponent(entity, 'position', { x: 0, y: 0 });
    world.removeEntity(entity);
    expect(world.getComponent(entity, 'position')).toBeUndefined();
    expect(world.query('position')).toEqual([]);
  });

  it('removes individual components', () => {
    const world = new World();
    const entity = world.createEntity();
    world.addComponent(entity, 'position', { x: 0, y: 0 });
    world.addComponent(entity, 'needs', { hunger: 100, energy: 100 });
    world.removeComponent(entity, 'position');
    expect(world.getComponent(entity, 'position')).toBeUndefined();
    expect(world.getComponent(entity, 'needs')).toEqual({ hunger: 100, energy: 100 });
  });
});

Step 2: Run tests to verify they fail

cd D:/gamedev/dflike && npm -w server run test

Expected: FAIL — World module not found.

Step 3: Implement ECS World

server/src/ecs/World.ts:

import type { EntityId } from '@dflike/shared';

type ComponentName = string;

export class World {
  private nextId: EntityId = 1;
  private components: Map<ComponentName, Map<EntityId, unknown>> = new Map();
  private entities: Set<EntityId> = new Set();

  createEntity(): EntityId {
    const id = this.nextId++;
    this.entities.add(id);
    return id;
  }

  removeEntity(entity: EntityId): void {
    this.entities.delete(entity);
    for (const store of this.components.values()) {
      store.delete(entity);
    }
  }

  addComponent<T>(entity: EntityId, name: ComponentName, data: T): void {
    if (!this.components.has(name)) {
      this.components.set(name, new Map());
    }
    this.components.get(name)!.set(entity, data);
  }

  getComponent<T>(entity: EntityId, name: ComponentName): T | undefined {
    return this.components.get(name)?.get(entity) as T | undefined;
  }

  removeComponent(entity: EntityId, name: ComponentName): void {
    this.components.get(name)?.delete(entity);
  }

  query(...componentNames: ComponentName[]): EntityId[] {
    const result: EntityId[] = [];
    for (const entity of this.entities) {
      if (componentNames.every(name => this.components.get(name)?.has(entity))) {
        result.push(entity);
      }
    }
    return result;
  }

  getAllEntities(): EntityId[] {
    return [...this.entities];
  }
}

Step 4: Run tests to verify they pass

cd D:/gamedev/dflike && npm -w server run test

Expected: All 6 tests PASS.

Step 5: Commit

git add server/src/ecs/
git commit -m "feat: implement lightweight ECS core with tests"

Task 4: World Map and Pathfinding

Files:

  • Create: server/src/map/GameMap.ts
  • Create: server/src/map/pathfinding.ts
  • Create: server/src/map/__tests__/pathfinding.test.ts

Step 1: Implement GameMap

server/src/map/GameMap.ts:

import { WORLD_WIDTH, WORLD_HEIGHT, type Position } from '@dflike/shared';

export interface PointOfInterest {
  type: 'food' | 'rest';
  position: Position;
}

export class GameMap {
  readonly width: number;
  readonly height: number;
  private obstacles: Set<string> = new Set();
  private pois: PointOfInterest[] = [];

  constructor(width = WORLD_WIDTH, height = WORLD_HEIGHT) {
    this.width = width;
    this.height = height;
  }

  private key(x: number, y: number): string {
    return `${x},${y}`;
  }

  setObstacle(x: number, y: number): void {
    this.obstacles.add(this.key(x, y));
  }

  isWalkable(x: number, y: number): boolean {
    if (x < 0 || y < 0 || x >= this.width || y >= this.height) return false;
    return !this.obstacles.has(this.key(x, y));
  }

  addPointOfInterest(poi: PointOfInterest): void {
    this.pois.push(poi);
  }

  getPointsOfInterest(type?: 'food' | 'rest'): PointOfInterest[] {
    if (type) return this.pois.filter(p => p.type === type);
    return this.pois;
  }

  getObstacles(): Position[] {
    return [...this.obstacles].map(k => {
      const [x, y] = k.split(',').map(Number);
      return { x, y };
    });
  }

  getRandomWalkable(): Position {
    let x: number, y: number;
    do {
      x = Math.floor(Math.random() * this.width);
      y = Math.floor(Math.random() * this.height);
    } while (!this.isWalkable(x, y));
    return { x, y };
  }
}

Step 2: Write failing pathfinding tests

server/src/map/__tests__/pathfinding.test.ts:

import { describe, it, expect } from 'vitest';
import { findPath } from '../pathfinding.js';
import { GameMap } from '../GameMap.js';

describe('findPath (A*)', () => {
  it('finds a straight path with no obstacles', () => {
    const map = new GameMap(10, 10);
    const path = findPath(map, { x: 0, y: 0 }, { x: 3, y: 0 });
    expect(path).not.toBeNull();
    expect(path!.length).toBeGreaterThan(0);
    expect(path![path!.length - 1]).toEqual({ x: 3, y: 0 });
  });

  it('finds a path around an obstacle', () => {
    const map = new GameMap(10, 10);
    map.setObstacle(1, 0);
    map.setObstacle(1, 1);
    const path = findPath(map, { x: 0, y: 0 }, { x: 2, y: 0 });
    expect(path).not.toBeNull();
    expect(path!.some(p => p.x === 1 && p.y === 0)).toBe(false);
    expect(path![path!.length - 1]).toEqual({ x: 2, y: 0 });
  });

  it('returns null if no path exists', () => {
    const map = new GameMap(5, 5);
    // Wall off the target
    for (let y = 0; y < 5; y++) map.setObstacle(2, y);
    const path = findPath(map, { x: 0, y: 0 }, { x: 4, y: 0 });
    expect(path).toBeNull();
  });

  it('returns empty path if start equals goal', () => {
    const map = new GameMap(10, 10);
    const path = findPath(map, { x: 3, y: 3 }, { x: 3, y: 3 });
    expect(path).toEqual([]);
  });

  it('does not include the start position in the path', () => {
    const map = new GameMap(10, 10);
    const path = findPath(map, { x: 0, y: 0 }, { x: 2, y: 0 });
    expect(path).not.toBeNull();
    expect(path![0]).not.toEqual({ x: 0, y: 0 });
  });
});

Step 3: Run tests to verify they fail

cd D:/gamedev/dflike && npm -w server run test

Expected: FAIL — pathfinding module not found.

Step 4: Implement A pathfinding*

server/src/map/pathfinding.ts:

import type { Position } from '@dflike/shared';
import type { GameMap } from './GameMap.js';

interface Node {
  x: number;
  y: number;
  g: number;
  h: number;
  f: number;
  parent: Node | null;
}

function heuristic(a: Position, b: Position): number {
  return Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
}

const NEIGHBORS = [
  { dx: 0, dy: -1 },
  { dx: 0, dy: 1 },
  { dx: -1, dy: 0 },
  { dx: 1, dy: 0 },
];

export function findPath(map: GameMap, start: Position, goal: Position): Position[] | null {
  if (start.x === goal.x && start.y === goal.y) return [];

  const open: Node[] = [];
  const closed = new Set<string>();
  const key = (x: number, y: number) => `${x},${y}`;

  const startNode: Node = {
    x: start.x, y: start.y,
    g: 0, h: heuristic(start, goal), f: heuristic(start, goal),
    parent: null,
  };
  open.push(startNode);

  while (open.length > 0) {
    // Find lowest f
    let bestIdx = 0;
    for (let i = 1; i < open.length; i++) {
      if (open[i].f < open[bestIdx].f) bestIdx = i;
    }
    const current = open.splice(bestIdx, 1)[0];

    if (current.x === goal.x && current.y === goal.y) {
      // Reconstruct path (excluding start)
      const path: Position[] = [];
      let node: Node | null = current;
      while (node && !(node.x === start.x && node.y === start.y)) {
        path.push({ x: node.x, y: node.y });
        node = node.parent;
      }
      return path.reverse();
    }

    closed.add(key(current.x, current.y));

    for (const { dx, dy } of NEIGHBORS) {
      const nx = current.x + dx;
      const ny = current.y + dy;
      if (!map.isWalkable(nx, ny) || closed.has(key(nx, ny))) continue;

      const g = current.g + 1;
      const h = heuristic({ x: nx, y: ny }, goal);
      const existing = open.find(n => n.x === nx && n.y === ny);

      if (existing) {
        if (g < existing.g) {
          existing.g = g;
          existing.f = g + h;
          existing.parent = current;
        }
      } else {
        open.push({ x: nx, y: ny, g, h, f: g + h, parent: current });
      }
    }
  }

  return null; // No path found
}

Step 5: Run tests to verify they pass

cd D:/gamedev/dflike && npm -w server run test

Expected: All pathfinding tests PASS.

Step 6: Commit

git add server/src/map/
git commit -m "feat: add GameMap and A* pathfinding with tests"

Task 5: Server Systems (NeedsDecay, NPCBrain, Pathfinding, Movement)

Files:

  • Create: server/src/systems/needsDecaySystem.ts
  • Create: server/src/systems/npcBrainSystem.ts
  • Create: server/src/systems/pathfindingSystem.ts
  • Create: server/src/systems/movementSystem.ts
  • Create: server/src/systems/__tests__/systems.test.ts

Step 1: Write failing tests for systems

server/src/systems/__tests__/systems.test.ts:

import { describe, it, expect } from 'vitest';
import { World } from '../../ecs/World.js';
import { GameMap } from '../../map/GameMap.js';
import { needsDecaySystem } from '../needsDecaySystem.js';
import { npcBrainSystem } from '../npcBrainSystem.js';
import { movementSystem } from '../movementSystem.js';
import type { Needs, Movement, NPCBrain, Position } from '@dflike/shared';
import { HUNGER_DECAY_PER_TICK, ENERGY_DECAY_PER_TICK } from '@dflike/shared';

function createNPC(world: World, x: number, y: number, hunger = 80, energy = 80) {
  const e = world.createEntity();
  world.addComponent<Position>(e, 'position', { x, y });
  world.addComponent<Needs>(e, 'needs', { hunger, energy });
  world.addComponent<Movement>(e, 'movement', { state: 'idle', target: null, path: [], direction: 0 });
  world.addComponent<NPCBrain>(e, 'npcBrain', { currentGoal: null, goalQueue: [] });
  return e;
}

describe('needsDecaySystem', () => {
  it('decays hunger and energy each tick', () => {
    const world = new World();
    const e = createNPC(world, 0, 0, 50, 50);
    needsDecaySystem(world);
    const needs = world.getComponent<Needs>(e, 'needs')!;
    expect(needs.hunger).toBeCloseTo(50 - HUNGER_DECAY_PER_TICK);
    expect(needs.energy).toBeCloseTo(50 - ENERGY_DECAY_PER_TICK);
  });

  it('clamps needs at 0', () => {
    const world = new World();
    const e = createNPC(world, 0, 0, 0.01, 0.01);
    needsDecaySystem(world);
    const needs = world.getComponent<Needs>(e, 'needs')!;
    expect(needs.hunger).toBeGreaterThanOrEqual(0);
    expect(needs.energy).toBeGreaterThanOrEqual(0);
  });
});

describe('npcBrainSystem', () => {
  it('sets wander goal when needs are satisfied', () => {
    const world = new World();
    const map = new GameMap(10, 10);
    const e = createNPC(world, 5, 5, 80, 80);
    npcBrainSystem(world, map);
    const brain = world.getComponent<NPCBrain>(e, 'npcBrain')!;
    expect(brain.currentGoal).toBe('wander');
  });

  it('sets eat goal when hunger is low', () => {
    const world = new World();
    const map = new GameMap(10, 10);
    map.addPointOfInterest({ type: 'food', position: { x: 3, y: 3 } });
    const e = createNPC(world, 5, 5, 15, 80);
    npcBrainSystem(world, map);
    const brain = world.getComponent<NPCBrain>(e, 'npcBrain')!;
    expect(brain.currentGoal).toBe('eat');
  });

  it('sets rest goal when energy is low', () => {
    const world = new World();
    const map = new GameMap(10, 10);
    map.addPointOfInterest({ type: 'rest', position: { x: 7, y: 7 } });
    const e = createNPC(world, 5, 5, 80, 10);
    npcBrainSystem(world, map);
    const brain = world.getComponent<NPCBrain>(e, 'npcBrain')!;
    expect(brain.currentGoal).toBe('rest');
  });

  it('prioritizes energy over hunger', () => {
    const world = new World();
    const map = new GameMap(10, 10);
    map.addPointOfInterest({ type: 'food', position: { x: 3, y: 3 } });
    map.addPointOfInterest({ type: 'rest', position: { x: 7, y: 7 } });
    const e = createNPC(world, 5, 5, 15, 10);
    npcBrainSystem(world, map);
    const brain = world.getComponent<NPCBrain>(e, 'npcBrain')!;
    expect(brain.currentGoal).toBe('rest');
  });
});

describe('movementSystem', () => {
  it('moves entity along path', () => {
    const world = new World();
    const e = world.createEntity();
    world.addComponent<Position>(e, 'position', { x: 0, y: 0 });
    world.addComponent<Movement>(e, 'movement', {
      state: 'walking',
      target: { x: 2, y: 0 },
      path: [{ x: 1, y: 0 }, { x: 2, y: 0 }],
      direction: 2, // RIGHT
    });
    movementSystem(world);
    const pos = world.getComponent<Position>(e, 'position')!;
    expect(pos).toEqual({ x: 1, y: 0 });
  });

  it('sets idle when path is exhausted', () => {
    const world = new World();
    const e = world.createEntity();
    world.addComponent<Position>(e, 'position', { x: 1, y: 0 });
    world.addComponent<Movement>(e, 'movement', {
      state: 'walking',
      target: { x: 1, y: 0 },
      path: [],
      direction: 2,
    });
    movementSystem(world);
    const mov = world.getComponent<Movement>(e, 'movement')!;
    expect(mov.state).toBe('idle');
    expect(mov.target).toBeNull();
  });
});

Step 2: Run tests to verify they fail

cd D:/gamedev/dflike && npm -w server run test

Expected: FAIL — system modules not found.

Step 3: Implement needsDecaySystem

server/src/systems/needsDecaySystem.ts:

import { HUNGER_DECAY_PER_TICK, ENERGY_DECAY_PER_TICK, type Needs } from '@dflike/shared';
import type { World } from '../ecs/World.js';

export function needsDecaySystem(world: World): void {
  for (const entity of world.query('needs')) {
    const needs = world.getComponent<Needs>(entity, 'needs')!;
    needs.hunger = Math.max(0, needs.hunger - HUNGER_DECAY_PER_TICK);
    needs.energy = Math.max(0, needs.energy - ENERGY_DECAY_PER_TICK);
  }
}

Step 4: Implement npcBrainSystem

server/src/systems/npcBrainSystem.ts:

import {
  HUNGER_THRESHOLD, ENERGY_THRESHOLD,
  type Needs, type Movement, type NPCBrain, type Position,
} from '@dflike/shared';
import type { World } from '../ecs/World.js';
import type { GameMap } from '../map/GameMap.js';
import { findPath } from '../map/pathfinding.js';

function closestPOI(map: GameMap, from: Position, type: 'food' | 'rest'): Position | null {
  const pois = map.getPointsOfInterest(type);
  if (pois.length === 0) return null;
  let best = pois[0].position;
  let bestDist = Math.abs(from.x - best.x) + Math.abs(from.y - best.y);
  for (let i = 1; i < pois.length; i++) {
    const d = Math.abs(from.x - pois[i].position.x) + Math.abs(from.y - pois[i].position.y);
    if (d < bestDist) { best = pois[i].position; bestDist = d; }
  }
  return best;
}

function directionTo(from: Position, to: Position): number {
  const dx = to.x - from.x;
  const dy = to.y - from.y;
  if (Math.abs(dx) > Math.abs(dy)) return dx > 0 ? 2 : 1; // RIGHT : LEFT
  return dy > 0 ? 0 : 3; // DOWN : UP
}

export function npcBrainSystem(world: World, map: GameMap): void {
  for (const entity of world.query('npcBrain', 'needs', 'movement', 'position')) {
    const brain = world.getComponent<NPCBrain>(entity, 'npcBrain')!;
    const needs = world.getComponent<Needs>(entity, 'needs')!;
    const movement = world.getComponent<Movement>(entity, 'movement')!;
    const pos = world.getComponent<Position>(entity, 'position')!;

    // Skip if currently walking toward a goal
    if (movement.state === 'walking' && movement.path.length > 0) continue;

    // Check if at goal destination — recover needs
    if (brain.currentGoal === 'eat' && movement.state === 'idle') {
      needs.hunger = Math.min(100, needs.hunger + 20);
      brain.currentGoal = null;
    }
    if (brain.currentGoal === 'rest' && movement.state === 'idle') {
      needs.energy = Math.min(100, needs.energy + 20);
      brain.currentGoal = null;
    }

    // Determine new goal based on needs priority
    let goal: 'rest' | 'eat' | 'wander' = 'wander';
    if (needs.energy < ENERGY_THRESHOLD) goal = 'rest';
    else if (needs.hunger < HUNGER_THRESHOLD) goal = 'eat';

    brain.currentGoal = goal;

    // Pick target based on goal
    let target: Position | null = null;
    if (goal === 'eat') {
      target = closestPOI(map, pos, 'food');
    } else if (goal === 'rest') {
      target = closestPOI(map, pos, 'rest');
    }
    if (!target) {
      target = map.getRandomWalkable();
    }

    // Pathfind to target
    const path = findPath(map, pos, target);
    if (path && path.length > 0) {
      movement.state = 'walking';
      movement.target = target;
      movement.path = path;
      movement.direction = directionTo(pos, path[0]);
    }
  }
}

Step 5: Implement movementSystem

server/src/systems/movementSystem.ts:

import { Direction, type Movement, type Position } from '@dflike/shared';
import type { World } from '../ecs/World.js';

function directionFromDelta(dx: number, dy: number): number {
  if (Math.abs(dx) > Math.abs(dy)) return dx > 0 ? Direction.RIGHT : Direction.LEFT;
  return dy > 0 ? Direction.DOWN : Direction.UP;
}

export function movementSystem(world: World): void {
  for (const entity of world.query('position', 'movement')) {
    const movement = world.getComponent<Movement>(entity, 'movement')!;
    const pos = world.getComponent<Position>(entity, 'position')!;

    if (movement.state !== 'walking' || movement.path.length === 0) {
      if (movement.state === 'walking' && movement.path.length === 0) {
        movement.state = 'idle';
        movement.target = null;
      }
      continue;
    }

    const next = movement.path.shift()!;
    movement.direction = directionFromDelta(next.x - pos.x, next.y - pos.y);
    pos.x = next.x;
    pos.y = next.y;

    if (movement.path.length === 0) {
      movement.state = 'idle';
      movement.target = null;
    }
  }
}

Step 6: Run all tests

cd D:/gamedev/dflike && npm -w server run test

Expected: All tests PASS.

Step 7: Commit

git add server/src/systems/
git commit -m "feat: implement NPC systems (needs decay, brain, pathfinding, movement)"

Task 6: Appearance Generation

Files:

  • Create: server/src/spawner/appearanceGenerator.ts
  • Create: server/src/spawner/__tests__/appearanceGenerator.test.ts

Step 1: Write failing tests

server/src/spawner/__tests__/appearanceGenerator.test.ts:

import { describe, it, expect } from 'vitest';
import { generateRandomAppearance, SKIN_OPTIONS, ACCESSORY_OPTIONS } from '../appearanceGenerator.js';
import { ACCESSORY_SLOTS } from '@dflike/shared';

describe('generateRandomAppearance', () => {
  it('returns a valid skinId', () => {
    const appearance = generateRandomAppearance();
    expect(SKIN_OPTIONS).toContain(appearance.skinId);
  });

  it('only uses valid accessory slots', () => {
    const appearance = generateRandomAppearance();
    for (const slot of Object.keys(appearance.accessories)) {
      expect(ACCESSORY_SLOTS).toContain(slot);
    }
  });

  it('generates different appearances on multiple calls', () => {
    const appearances = new Set<string>();
    for (let i = 0; i < 20; i++) {
      appearances.add(JSON.stringify(generateRandomAppearance()));
    }
    // With so many options, 20 calls should produce at least 2 unique results
    expect(appearances.size).toBeGreaterThan(1);
  });
});

Step 2: Run tests to verify they fail

cd D:/gamedev/dflike && npm -w server run test

Step 3: Implement appearance generator

This defines the available asset options based on the actual file structure in chars/baseman/accessories/sprites/. The server doesn't load images — it just picks IDs that the client will use to load the correct files.

server/src/spawner/appearanceGenerator.ts:

import type { Appearance, AccessorySlot } from '@dflike/shared';
import { ACCESSORY_SLOTS } from '@dflike/shared';

// Skin options (from chars/baseman/accessories/sprites/skins/)
export const SKIN_OPTIONS = [
  'shape00_skin00', 'shape00_skin01', 'shape00_skin02',
  'shape00_skin03', 'shape00_skin04', 'shape00_skin05', 'shape00_skin06',
];

// Accessory options per slot: array of filenames (without .png)
// Each entry is one valid option the client can load
export const ACCESSORY_OPTIONS: Record<AccessorySlot, string[]> = {
  arm: [
    '00_c00', '00_c01', '00_c02', '00_c03',
    '02_c00', '02_c01', '02_c02', '02_c03',
    '03_c00', '03_c01', '03_c02', '03_c03',
  ],
  back: [
    '00_c00', '00_c01', '00_c02', '00_c03',
    '01_c00', '01_c01', '01_c02', '01_c03',
    '02_c00', '02_c01', '02_c02', '02_c03',
  ],
  bottom: [
    '00_c00', '00_c01', '00_c02', '00_c03',
    '01_c00', '01_c01', '01_c02', '01_c03',
    '02_c00', '02_c01', '02_c02', '02_c03',
  ],
  chest: [
    '00_c00', '00_c01', '00_c02', '00_c03',
    '01_c00', '01_c01', '01_c02', '01_c03',
    '02_c00', '02_c01', '02_c02', '02_c03',
    '03_c00', '03_c01', '03_c02', '03_c03',
  ],
  facialHair: [
    '00_c00', '00_c01', '00_c02', '00_c03',
    '01_c00', '01_c01', '01_c02', '01_c03',
  ],
  feet: [
    '00_c00', '00_c01', '00_c02', '00_c03',
    '01_c00', '01_c01', '01_c02', '01_c03',
  ],
  haircut: [
    '00_c00', '00_c01', '00_c02', '00_c03',
    '01_c00', '01_c01', '01_c02', '01_c03',
    '02_c00', '02_c01', '02_c02', '02_c03',
    '03_c00', '03_c01', '03_c02', '03_c03',
  ],
  hat: [
    '00_c00', '00_c01', '00_c02',
    '01_c00', '01_c01', '01_c02',
  ],
  shoulder: [
    '00_c00', '00_c01', '00_c02', '00_c03',
    '01_c00', '01_c01', '01_c02', '01_c03',
  ],
  waist: [
    '00_c00', '00_c01', '00_c02', '00_c03',
    '01_c00', '01_c01', '01_c02', '01_c03',
  ],
};

function pick<T>(arr: T[]): T {
  return arr[Math.floor(Math.random() * arr.length)];
}

export function generateRandomAppearance(): Appearance {
  const skinId = pick(SKIN_OPTIONS);
  const accessories: Partial<Record<AccessorySlot, string>> = {};

  for (const slot of ACCESSORY_SLOTS) {
    // 70% chance to have each accessory (skip some for variety)
    // Always include haircut and chest for a reasonable look
    const alwaysInclude = slot === 'haircut' || slot === 'chest' || slot === 'bottom';
    if (alwaysInclude || Math.random() < 0.4) {
      const options = ACCESSORY_OPTIONS[slot];
      if (options && options.length > 0) {
        accessories[slot] = pick(options);
      }
    }
  }

  return { skinId, accessories };
}

Step 4: Run tests to verify they pass

cd D:/gamedev/dflike && npm -w server run test

Step 5: Commit

git add server/src/spawner/
git commit -m "feat: add random appearance generator for NPC spawning"

Task 7: Server Game Loop and NPC Spawning

Files:

  • Create: server/src/game/GameLoop.ts
  • Create: server/src/game/spawner.ts

Step 1: Implement NPC spawner

server/src/game/spawner.ts:

import type { World } from '../ecs/World.js';
import type { GameMap } from '../map/GameMap.js';
import { generateRandomAppearance } from '../spawner/appearanceGenerator.js';
import type { EntityId, Position, Needs, Movement, NPCBrain, Appearance } from '@dflike/shared';

export function spawnNPC(world: World, map: GameMap): EntityId {
  const entity = world.createEntity();
  const pos = map.getRandomWalkable();

  world.addComponent<Position>(entity, 'position', pos);
  world.addComponent<Needs>(entity, 'needs', {
    hunger: 40 + Math.random() * 40, // 40-80
    energy: 40 + Math.random() * 40,
  });
  world.addComponent<Movement>(entity, 'movement', {
    state: 'idle',
    target: null,
    path: [],
    direction: 0,
  });
  world.addComponent<NPCBrain>(entity, 'npcBrain', {
    currentGoal: null,
    goalQueue: [],
  });
  world.addComponent<Appearance>(entity, 'appearance', generateRandomAppearance());

  return entity;
}

Step 2: Implement GameLoop

server/src/game/GameLoop.ts:

import { TICK_RATE, BROADCAST_EVERY_N_TICKS } from '@dflike/shared';
import { World } from '../ecs/World.js';
import { GameMap } from '../map/GameMap.js';
import { needsDecaySystem } from '../systems/needsDecaySystem.js';
import { npcBrainSystem } from '../systems/npcBrainSystem.js';
import { movementSystem } from '../systems/movementSystem.js';
import { spawnNPC } from './spawner.js';

export class GameLoop {
  readonly world: World;
  readonly map: GameMap;
  private tick = 0;
  private interval: ReturnType<typeof setInterval> | null = null;
  private onBroadcast: (() => void) | null = null;

  constructor() {
    this.world = new World();
    this.map = new GameMap();
    this.setupMap();
    this.spawnInitialNPCs(8);
  }

  private setupMap(): void {
    // Add some obstacle clusters for pathfinding interest
    // Small building-like structures
    for (let x = 10; x <= 14; x++) for (let y = 10; y <= 12; y++) this.map.setObstacle(x, y);
    for (let x = 30; x <= 33; x++) for (let y = 25; y <= 28; y++) this.map.setObstacle(x, y);
    for (let x = 50; x <= 53; x++) for (let y = 40; y <= 43; y++) this.map.setObstacle(x, y);

    // Points of interest
    this.map.addPointOfInterest({ type: 'food', position: { x: 15, y: 15 } });
    this.map.addPointOfInterest({ type: 'food', position: { x: 45, y: 30 } });
    this.map.addPointOfInterest({ type: 'rest', position: { x: 8, y: 8 } });
    this.map.addPointOfInterest({ type: 'rest', position: { x: 55, y: 50 } });
  }

  private spawnInitialNPCs(count: number): void {
    for (let i = 0; i < count; i++) {
      spawnNPC(this.world, this.map);
    }
  }

  setBroadcastHandler(handler: () => void): void {
    this.onBroadcast = handler;
  }

  start(): void {
    const tickInterval = 1000 / TICK_RATE;
    this.interval = setInterval(() => this.update(), tickInterval);
    console.log(`Game loop started at ${TICK_RATE} ticks/sec`);
  }

  stop(): void {
    if (this.interval) {
      clearInterval(this.interval);
      this.interval = null;
    }
  }

  private update(): void {
    this.tick++;

    // Run systems in order
    needsDecaySystem(this.world);
    npcBrainSystem(this.world, this.map);
    movementSystem(this.world);

    // Broadcast state periodically
    if (this.tick % BROADCAST_EVERY_N_TICKS === 0 && this.onBroadcast) {
      this.onBroadcast();
    }
  }

  getTick(): number {
    return this.tick;
  }
}

Step 3: Verify server compiles

cd D:/gamedev/dflike && npx -w server tsc --noEmit

Step 4: Commit

git add server/src/game/
git commit -m "feat: add game loop with NPC spawning and system execution"

Task 8: Server Networking (Socket.io)

Files:

  • Create: server/src/network/SocketServer.ts
  • Create: server/src/network/stateSerializer.ts
  • Create: server/src/main.ts

Step 1: Implement state serializer

server/src/network/stateSerializer.ts:

import type {
  EntityState, WorldState, StateUpdate,
  Position, Movement, Appearance, Needs, NPCBrain, PlayerControlled,
} from '@dflike/shared';
import { TILE_SIZE } from '@dflike/shared';
import type { World } from '../ecs/World.js';
import type { GameMap } from '../map/GameMap.js';

export function serializeEntity(world: World, entityId: number): EntityState {
  return {
    id: entityId,
    position: world.getComponent<Position>(entityId, 'position')!,
    movement: world.getComponent<Movement>(entityId, 'movement')!,
    appearance: world.getComponent<Appearance>(entityId, 'appearance')!,
    needs: world.getComponent<Needs>(entityId, 'needs'),
    npcBrain: world.getComponent<NPCBrain>(entityId, 'npcBrain'),
    playerControlled: world.getComponent<PlayerControlled>(entityId, 'playerControlled'),
  };
}

export function serializeWorldState(world: World, map: GameMap): WorldState {
  const entities = world.query('position').map(id => serializeEntity(world, id));
  return {
    entities,
    worldWidth: map.width,
    worldHeight: map.height,
    tileSize: TILE_SIZE,
    obstacles: map.getObstacles(),
    pointsOfInterest: map.getPointsOfInterest(),
  };
}

export function serializeStateUpdate(world: World, tick: number): StateUpdate {
  const entities = world.query('position').map(id => serializeEntity(world, id));
  return { entities, tick };
}

Step 2: Implement SocketServer

server/src/network/SocketServer.ts:

import { Server } from 'socket.io';
import type http from 'http';
import type { ClientEvents, ServerEvents, PlayerInput, Position, Movement, PlayerControlled } from '@dflike/shared';
import { Direction } from '@dflike/shared';
import type { GameLoop } from '../game/GameLoop.js';
import { serializeWorldState, serializeStateUpdate } from './stateSerializer.js';

export class SocketServer {
  private io: Server<ClientEvents, ServerEvents>;
  private gameLoop: GameLoop;

  constructor(httpServer: http.Server, gameLoop: GameLoop) {
    this.io = new Server(httpServer, {
      cors: { origin: '*' },
    });
    this.gameLoop = gameLoop;

    this.setupBroadcast();
    this.setupConnections();
  }

  private setupBroadcast(): void {
    this.gameLoop.setBroadcastHandler(() => {
      const update = serializeStateUpdate(this.gameLoop.world, this.gameLoop.getTick());
      this.io.emit('state-update', update);
    });
  }

  private setupConnections(): void {
    this.io.on('connection', (socket) => {
      const playerId = socket.id;
      console.log(`Player connected: ${playerId}`);

      // Create player entity (starts in camera mode — no avatar)
      const world = this.gameLoop.world;
      const map = this.gameLoop.map;
      const entity = world.createEntity();
      const startPos = map.getRandomWalkable();
      world.addComponent<Position>(entity, 'position', startPos);
      world.addComponent<Movement>(entity, 'movement', {
        state: 'idle', target: null, path: [], direction: Direction.DOWN,
      });
      world.addComponent<PlayerControlled>(entity, 'playerControlled', {
        playerId, mode: 'camera',
      });

      // Send full world state
      socket.emit('world-state', serializeWorldState(world, map));
      socket.emit('player-joined', { playerId, entityId: entity });

      // Notify others
      socket.broadcast.emit('player-joined', { playerId, entityId: entity });

      // Handle inputs
      socket.on('player-input', (input: PlayerInput) => {
        this.handleInput(playerId, entity, input);
      });

      // Handle disconnect
      socket.on('disconnect', () => {
        console.log(`Player disconnected: ${playerId}`);
        world.removeEntity(entity);
        this.io.emit('player-left', { playerId });
      });
    });
  }

  private handleInput(playerId: string, entityId: number, input: PlayerInput): void {
    const world = this.gameLoop.world;
    const pc = world.getComponent<PlayerControlled>(entityId, 'playerControlled');
    if (!pc) return;

    switch (input.type) {
      case 'toggle-mode': {
        pc.mode = pc.mode === 'avatar' ? 'camera' : 'avatar';
        break;
      }
      case 'move': {
        if (pc.mode !== 'avatar' || !input.direction) break;
        const pos = world.getComponent<Position>(entityId, 'position');
        const mov = world.getComponent<Movement>(entityId, 'movement');
        if (!pos || !mov) break;

        const newX = pos.x + input.direction.dx;
        const newY = pos.y + input.direction.dy;
        if (this.gameLoop.map.isWalkable(newX, newY)) {
          pos.x = newX;
          pos.y = newY;

          // Set direction for animation
          const { dx, dy } = input.direction;
          if (Math.abs(dx) > Math.abs(dy)) {
            mov.direction = dx > 0 ? Direction.RIGHT : Direction.LEFT;
          } else {
            mov.direction = dy > 0 ? Direction.DOWN : Direction.UP;
          }
        }
        break;
      }
    }
  }
}

Step 3: Implement server main entry point

server/src/main.ts:

import http from 'http';
import { GameLoop } from './game/GameLoop.js';
import { SocketServer } from './network/SocketServer.js';

const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3001;

const gameLoop = new GameLoop();
const httpServer = http.createServer();
new SocketServer(httpServer, gameLoop);

httpServer.listen(PORT, () => {
  console.log(`Server listening on port ${PORT}`);
  gameLoop.start();
});

Step 4: Start server to verify it runs

cd D:/gamedev/dflike && npm -w server run dev

Expected: "Server listening on port 3001" and "Game loop started at 10 ticks/sec". Ctrl+C to stop.

Step 5: Commit

git add server/src/network/ server/src/main.ts
git commit -m "feat: add Socket.io server with state broadcasting and player input handling"

Task 9: Client Entry Point and Boot Scene

Files:

  • Create: client/src/main.ts
  • Create: client/src/scenes/BootScene.ts
  • Create: client/src/network/SocketClient.ts

Step 1: Implement SocketClient

client/src/network/SocketClient.ts:

import { io, Socket } from 'socket.io-client';
import type { ServerEvents, ClientEvents, WorldState, StateUpdate, PlayerJoined, PlayerLeft, PlayerInput } from '@dflike/shared';

type TypedSocket = Socket<ServerEvents, ClientEvents>;

export class SocketClient {
  private socket: TypedSocket;
  private _playerId: string | null = null;
  private _entityId: number | null = null;

  // Event callbacks
  onWorldState: ((data: WorldState) => void) | null = null;
  onStateUpdate: ((data: StateUpdate) => void) | null = null;
  onPlayerJoined: ((data: PlayerJoined) => void) | null = null;
  onPlayerLeft: ((data: PlayerLeft) => void) | null = null;

  constructor(url: string) {
    this.socket = io(url);

    this.socket.on('world-state', (data) => {
      this.onWorldState?.(data);
    });

    this.socket.on('state-update', (data) => {
      this.onStateUpdate?.(data);
    });

    this.socket.on('player-joined', (data) => {
      if (!this._playerId) {
        this._playerId = data.playerId;
        this._entityId = data.entityId;
      }
      this.onPlayerJoined?.(data);
    });

    this.socket.on('player-left', (data) => {
      this.onPlayerLeft?.(data);
    });
  }

  get playerId(): string | null { return this._playerId; }
  get entityId(): number | null { return this._entityId; }

  sendInput(input: PlayerInput): void {
    this.socket.emit('player-input', input);
  }

  disconnect(): void {
    this.socket.disconnect();
  }
}

Step 2: Implement BootScene

client/src/scenes/BootScene.ts:

import Phaser from 'phaser';
import { SocketClient } from '../network/SocketClient.js';
import type { WorldState } from '@dflike/shared';

export class BootScene extends Phaser.Scene {
  private client!: SocketClient;

  constructor() {
    super({ key: 'BootScene' });
  }

  create(): void {
    const statusText = this.add.text(
      this.cameras.main.centerX,
      this.cameras.main.centerY,
      'Connecting to server...',
      { fontSize: '24px', color: '#ffffff' }
    ).setOrigin(0.5);

    const serverUrl = 'http://localhost:3001';
    this.client = new SocketClient(serverUrl);

    this.client.onWorldState = (worldState: WorldState) => {
      statusText.setText('Connected! Loading game...');
      this.scene.start('GameScene', {
        client: this.client,
        worldState,
      });
    };
  }
}

Step 3: Implement Phaser main entry point

client/src/main.ts:

import Phaser from 'phaser';
import { BootScene } from './scenes/BootScene.js';
import { GameScene } from './scenes/GameScene.js';

const config: Phaser.Types.Core.GameConfig = {
  type: Phaser.AUTO,
  parent: 'game',
  width: 800,
  height: 600,
  backgroundColor: '#1a1a2e',
  pixelArt: true,
  scene: [BootScene, GameScene],
};

new Phaser.Game(config);

Note: GameScene is referenced here but implemented in the next task. Create a stub so it compiles:

client/src/scenes/GameScene.ts (stub — replaced in Task 10):

import Phaser from 'phaser';

export class GameScene extends Phaser.Scene {
  constructor() {
    super({ key: 'GameScene' });
  }

  init(_data: unknown): void {}
  create(): void {
    this.add.text(100, 100, 'Game scene loaded', { color: '#ffffff' });
  }
}

Step 4: Verify client builds

cd D:/gamedev/dflike && npx -w client vite build

Step 5: Commit

git add client/src/
git commit -m "feat: add client entry point, boot scene, and socket client"

Task 10: Character Compositing on Client

Files:

  • Create: client/src/sprites/CharacterCompositor.ts

This is the system that takes an Appearance (skin + accessory IDs) and composites them into a single Phaser texture.

Step 1: Copy/symlink assets into client public directory

mkdir -p D:/gamedev/dflike/client/public/assets
cp -r D:/gamedev/dflike/chars D:/gamedev/dflike/client/public/assets/

Note: In production, a symlink or build step would be better. For now, copy.

Step 2: Implement CharacterCompositor

client/src/sprites/CharacterCompositor.ts:

import Phaser from 'phaser';
import type { Appearance } from '@dflike/shared';
import { ACCESSORY_SLOTS, SPRITE_FRAME_WIDTH, SPRITE_FRAME_HEIGHT, SPRITE_COLS, SPRITE_ROWS } from '@dflike/shared';

const SHEET_WIDTH = SPRITE_FRAME_WIDTH * SPRITE_COLS;   // 288
const SHEET_HEIGHT = SPRITE_FRAME_HEIGHT * SPRITE_ROWS;  // 192

function appearanceKey(appearance: Appearance): string {
  const accKeys = Object.entries(appearance.accessories)
    .sort(([a], [b]) => a.localeCompare(b))
    .map(([slot, id]) => `${slot}:${id}`)
    .join('|');
  return `char_${appearance.skinId}_${accKeys}`;
}

export class CharacterCompositor {
  private scene: Phaser.Scene;
  private loadedImages: Set<string> = new Set();

  constructor(scene: Phaser.Scene) {
    this.scene = scene;
  }

  private skinPath(skinId: string): string {
    return `assets/chars/baseman/accessories/sprites/skins/${skinId}.png`;
  }

  private accessoryPath(slot: string, fileId: string): string {
    return `assets/chars/baseman/accessories/sprites/${slot}/${fileId}.png`;
  }

  async preloadAppearance(appearance: Appearance): Promise<string> {
    const key = appearanceKey(appearance);

    // Already composited
    if (this.scene.textures.exists(key)) return key;

    // Collect all image paths needed
    const imagesToLoad: { key: string; path: string }[] = [];
    const skinKey = `skin_${appearance.skinId}`;
    if (!this.scene.textures.exists(skinKey) && !this.loadedImages.has(skinKey)) {
      imagesToLoad.push({ key: skinKey, path: this.skinPath(appearance.skinId) });
      this.loadedImages.add(skinKey);
    }

    for (const slot of ACCESSORY_SLOTS) {
      const fileId = appearance.accessories[slot];
      if (!fileId) continue;
      const accKey = `acc_${slot}_${fileId}`;
      if (!this.scene.textures.exists(accKey) && !this.loadedImages.has(accKey)) {
        imagesToLoad.push({ key: accKey, path: this.accessoryPath(slot, fileId) });
        this.loadedImages.add(accKey);
      }
    }

    // Load any missing images
    if (imagesToLoad.length > 0) {
      await new Promise<void>((resolve) => {
        for (const img of imagesToLoad) {
          this.scene.load.image(img.key, img.path);
        }
        this.scene.load.once('complete', resolve);
        this.scene.load.start();
      });
    }

    // Composite onto offscreen canvas
    const canvas = document.createElement('canvas');
    canvas.width = SHEET_WIDTH;
    canvas.height = SHEET_HEIGHT;
    const ctx = canvas.getContext('2d')!;

    // Draw skin base
    const skinTex = this.scene.textures.get(skinKey);
    if (skinTex.key !== '__MISSING') {
      ctx.drawImage(skinTex.getSourceImage() as HTMLImageElement, 0, 0);
    }

    // Draw accessories in z-order
    for (const slot of ACCESSORY_SLOTS) {
      const fileId = appearance.accessories[slot];
      if (!fileId) continue;
      const accKey = `acc_${slot}_${fileId}`;
      const accTex = this.scene.textures.get(accKey);
      if (accTex.key !== '__MISSING') {
        ctx.drawImage(accTex.getSourceImage() as HTMLImageElement, 0, 0);
      }
    }

    // Add composited texture to Phaser
    this.scene.textures.addSpriteSheet(key, canvas, {
      frameWidth: SPRITE_FRAME_WIDTH,
      frameHeight: SPRITE_FRAME_HEIGHT,
    });

    return key;
  }

  getKey(appearance: Appearance): string {
    return appearanceKey(appearance);
  }
}

Step 3: Verify client builds

cd D:/gamedev/dflike && npx -w client vite build

Step 4: Commit

git add client/src/sprites/ client/public/
git commit -m "feat: add character compositor that layers skin + accessories into cached textures"

Task 11: Game Scene (Rendering, Camera, Input)

Files:

  • Modify: client/src/scenes/GameScene.ts (replace stub)

Step 1: Implement full GameScene

Replace client/src/scenes/GameScene.ts:

import Phaser from 'phaser';
import type { SocketClient } from '../network/SocketClient.js';
import { CharacterCompositor } from '../sprites/CharacterCompositor.js';
import type { WorldState, StateUpdate, EntityState, Appearance } from '@dflike/shared';
import { TILE_SIZE, SPRITE_FRAME_WIDTH, SPRITE_FRAME_HEIGHT, SPRITE_COLS, Direction } from '@dflike/shared';

interface EntitySprite {
  sprite: Phaser.GameObjects.Sprite;
  lastState: EntityState;
}

export class GameScene extends Phaser.Scene {
  private client!: SocketClient;
  private compositor!: CharacterCompositor;
  private worldState!: WorldState;
  private entitySprites: Map<number, EntitySprite> = new Map();
  private cursors!: Phaser.Types.Input.Keyboard.CursorKeys;
  private wasd!: Record<string, Phaser.Input.Keyboard.Key>;
  private mode: 'avatar' | 'camera' = 'camera';
  private modeText!: Phaser.GameObjects.Text;
  private moveThrottle = 0;

  constructor() {
    super({ key: 'GameScene' });
  }

  init(data: { client: SocketClient; worldState: WorldState }): void {
    this.client = data.client;
    this.worldState = data.worldState;
  }

  create(): void {
    this.compositor = new CharacterCompositor(this);

    // Draw tile grid
    this.drawWorld();

    // Setup camera
    const worldPixelW = this.worldState.worldWidth * TILE_SIZE;
    const worldPixelH = this.worldState.worldHeight * TILE_SIZE;
    this.cameras.main.setBounds(0, 0, worldPixelW, worldPixelH);

    // Input
    this.cursors = this.input.keyboard!.createCursorKeys();
    this.wasd = {
      W: this.input.keyboard!.addKey('W'),
      A: this.input.keyboard!.addKey('A'),
      S: this.input.keyboard!.addKey('S'),
      D: this.input.keyboard!.addKey('D'),
    };

    // Tab to toggle mode
    this.input.keyboard!.addKey('TAB').on('down', () => {
      this.mode = this.mode === 'avatar' ? 'camera' : 'avatar';
      this.client.sendInput({ type: 'toggle-mode' });
      this.updateModeText();
    });

    // UI text
    this.modeText = this.add.text(10, 10, '', {
      fontSize: '16px', color: '#ffffff', backgroundColor: '#000000aa',
      padding: { x: 8, y: 4 },
    }).setScrollFactor(0).setDepth(1000);
    this.updateModeText();

    // Spawn initial entities
    this.spawnEntities(this.worldState.entities);

    // Listen for state updates
    this.client.onStateUpdate = (update: StateUpdate) => {
      this.handleStateUpdate(update);
    };

    this.client.onPlayerLeft = (data) => {
      // Entity removal will be handled by next state update
    };
  }

  private drawWorld(): void {
    const { worldWidth, worldHeight, obstacles, pointsOfInterest } = this.worldState;

    // Ground tiles
    const graphics = this.add.graphics();
    for (let x = 0; x < worldWidth; x++) {
      for (let y = 0; y < worldHeight; y++) {
        const shade = ((x + y) % 2 === 0) ? 0x2d5a27 : 0x326b2e;
        graphics.fillStyle(shade, 1);
        graphics.fillRect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE);
      }
    }

    // Obstacles
    for (const obs of obstacles) {
      graphics.fillStyle(0x555555, 1);
      graphics.fillRect(obs.x * TILE_SIZE, obs.y * TILE_SIZE, TILE_SIZE, TILE_SIZE);
    }

    // Points of interest
    for (const poi of pointsOfInterest) {
      const color = poi.type === 'food' ? 0xcc8833 : 0x3355cc;
      graphics.fillStyle(color, 1);
      graphics.fillRect(
        poi.position.x * TILE_SIZE + 8,
        poi.position.y * TILE_SIZE + 8,
        TILE_SIZE - 16,
        TILE_SIZE - 16,
      );
    }
  }

  private async spawnEntities(entities: EntityState[]): Promise<void> {
    for (const entity of entities) {
      if (!this.entitySprites.has(entity.id) && entity.appearance) {
        await this.createEntitySprite(entity);
      }
    }
  }

  private async createEntitySprite(entity: EntityState): Promise<void> {
    const textureKey = await this.compositor.preloadAppearance(entity.appearance);

    // Create animations for this texture
    this.createAnimations(textureKey);

    const sprite = this.add.sprite(
      entity.position.x * TILE_SIZE + TILE_SIZE / 2,
      entity.position.y * TILE_SIZE + TILE_SIZE / 2,
      textureKey,
      0,
    );

    this.entitySprites.set(entity.id, { sprite, lastState: entity });
    this.playAnimation(sprite, textureKey, entity.movement.direction, entity.movement.state);
  }

  private createAnimations(textureKey: string): void {
    const dirNames = ['down', 'left', 'right', 'up'];
    for (let dir = 0; dir < 4; dir++) {
      const walkKey = `${textureKey}_walk_${dirNames[dir]}`;
      const idleKey = `${textureKey}_idle_${dirNames[dir]}`;

      if (!this.anims.exists(walkKey)) {
        this.anims.create({
          key: walkKey,
          frames: this.anims.generateFrameNumbers(textureKey, {
            start: dir * SPRITE_COLS + 1,
            end: dir * SPRITE_COLS + SPRITE_COLS - 1,
          }),
          frameRate: 8,
          repeat: -1,
        });
      }
      if (!this.anims.exists(idleKey)) {
        this.anims.create({
          key: idleKey,
          frames: [{ key: textureKey, frame: dir * SPRITE_COLS }],
          frameRate: 1,
          repeat: 0,
        });
      }
    }
  }

  private playAnimation(sprite: Phaser.GameObjects.Sprite, textureKey: string, direction: number, state: string): void {
    const dirNames = ['down', 'left', 'right', 'up'];
    const dirName = dirNames[direction] ?? 'down';
    const animKey = state === 'walking'
      ? `${textureKey}_walk_${dirName}`
      : `${textureKey}_idle_${dirName}`;
    if (sprite.anims.currentAnim?.key !== animKey) {
      sprite.play(animKey);
    }
  }

  private handleStateUpdate(update: StateUpdate): void {
    const activeIds = new Set<number>();

    for (const entity of update.entities) {
      activeIds.add(entity.id);
      const existing = this.entitySprites.get(entity.id);

      if (existing) {
        // Interpolation target
        const targetX = entity.position.x * TILE_SIZE + TILE_SIZE / 2;
        const targetY = entity.position.y * TILE_SIZE + TILE_SIZE / 2;

        // Smooth movement via tween (if not already close)
        const dx = Math.abs(existing.sprite.x - targetX);
        const dy = Math.abs(existing.sprite.y - targetY);
        if (dx > 1 || dy > 1) {
          this.tweens.add({
            targets: existing.sprite,
            x: targetX,
            y: targetY,
            duration: 200,
            ease: 'Linear',
          });
        }

        // Update animation
        const textureKey = this.compositor.getKey(entity.appearance);
        this.playAnimation(existing.sprite, textureKey, entity.movement.direction, entity.movement.state);
        existing.lastState = entity;
      } else if (entity.appearance) {
        this.createEntitySprite(entity);
      }
    }

    // Remove entities no longer in update
    for (const [id, es] of this.entitySprites) {
      if (!activeIds.has(id)) {
        es.sprite.destroy();
        this.entitySprites.delete(id);
      }
    }
  }

  update(time: number, delta: number): void {
    this.handleCameraAndMovement(delta);
  }

  private handleCameraAndMovement(delta: number): void {
    const cam = this.cameras.main;
    const speed = 5; // tiles per second for camera pan

    const left = this.cursors.left?.isDown || this.wasd.A.isDown;
    const right = this.cursors.right?.isDown || this.wasd.D.isDown;
    const up = this.cursors.up?.isDown || this.wasd.W.isDown;
    const down = this.cursors.down?.isDown || this.wasd.S.isDown;

    if (this.mode === 'camera') {
      // Pan camera freely
      const panSpeed = speed * TILE_SIZE;
      if (left) cam.scrollX -= panSpeed * delta / 1000;
      if (right) cam.scrollX += panSpeed * delta / 1000;
      if (up) cam.scrollY -= panSpeed * delta / 1000;
      if (down) cam.scrollY += panSpeed * delta / 1000;
    } else {
      // Avatar mode — send movement input (throttled)
      this.moveThrottle -= delta;
      if (this.moveThrottle <= 0) {
        let dx = 0, dy = 0;
        if (left) dx = -1;
        else if (right) dx = 1;
        else if (up) dy = -1;
        else if (down) dy = 1;

        if (dx !== 0 || dy !== 0) {
          this.client.sendInput({ type: 'move', direction: { dx, dy } });
          this.moveThrottle = 150; // ms between moves
        }
      }

      // Follow own entity
      const myEntityId = this.client.entityId;
      if (myEntityId != null) {
        const mySprite = this.entitySprites.get(myEntityId);
        if (mySprite) {
          cam.centerOn(mySprite.sprite.x, mySprite.sprite.y);
        }
      }
    }
  }

  private updateModeText(): void {
    this.modeText.setText(
      `Mode: ${this.mode.toUpperCase()} [TAB to toggle]`
    );
  }
}

Step 2: Verify client builds

cd D:/gamedev/dflike && npx -w client vite build

Step 3: Commit

git add client/src/scenes/GameScene.ts
git commit -m "feat: implement game scene with entity rendering, camera, and input"

Task 12: Integration Test — Full Stack Run

Files: None new. This is a manual verification step.

Step 1: Start the server

cd D:/gamedev/dflike && npm -w server run dev

Expected output: "Server listening on port 3001" and "Game loop started at 10 ticks/sec"

Step 2: Start the client (in a new terminal)

cd D:/gamedev/dflike && npm -w client run dev

Expected: Vite dev server starts on http://localhost:3000

Step 3: Open browser and verify

Open http://localhost:3000. Expected behavior:

  • "Connecting to server..." text appears briefly
  • Game scene loads with a green tiled world
  • 8 NPCs visible with composited character sprites
  • NPCs wander around, pathfind around grey obstacles
  • Brown squares (food) and blue squares (rest) visible
  • Camera pans with WASD/arrow keys
  • TAB toggles to avatar mode, movement sends inputs to server
  • Open a second browser tab — second player connects, both see same NPCs

Step 4: Fix any issues found during integration

Common issues to watch for:

  • CORS on socket connection (already configured with cors: { origin: '*' })
  • Asset paths (verify chars folder is in client/public/assets/)
  • Spritesheet frame indexing (verify 6 cols x 4 rows = 24 frames)

Step 5: Commit any fixes

git add -A
git commit -m "fix: integration fixes from full-stack testing"

Task 13: Final Polish and README

Files:

  • Create: README.md
  • Create: CLAUDE.md

Step 1: Create README

README.md:

# DFlike

A multiplayer browser game inspired by Dwarf Fortress. Watch autonomous NPCs with needs-driven AI wander, eat, and rest in a shared world. Connect as an observer or take control of an avatar.

## Quick Start

```bash
npm install
# Terminal 1: Start server
npm -w server run dev
# Terminal 2: Start client
npm -w client run dev

Open http://localhost:3000. Open multiple tabs for multiplayer.

Controls

  • WASD / Arrow Keys — pan camera (camera mode) or move avatar (avatar mode)
  • TAB — toggle between camera and avatar mode

Architecture

  • Server: Node.js + Socket.io, server-authoritative ECS simulation at 10 ticks/sec
  • Client: Phaser 3 + TypeScript, Vite bundler
  • Shared: TypeScript types and constants

**Step 2: Create CLAUDE.md**

`CLAUDE.md`:
```markdown
# CLAUDE.md

## Project

Multiplayer NPC simulation game (Dwarf Fortress-inspired). Server-authoritative ECS architecture.

## Structure

- `shared/` — Types and constants (no runtime code)
- `server/` — Node.js + Socket.io game server with ECS
- `client/` — Phaser 3 + TypeScript renderer
- `chars/` — Character sprite assets (do not modify)
- `docs/plans/` — Design and implementation docs

## Commands

- `npm install` — install all workspace dependencies
- `npm -w server run dev` — start server (port 3001)
- `npm -w server run test` — run server tests
- `npm -w client run dev` — start client dev server (port 3000)
- `npm -w client run build` — build client for production

## Key Conventions

- ECS components are plain data objects, systems are pure functions
- Server owns all game state; clients are renderers
- Character sprites are 48x48 per frame, 6 cols x 4 rows spritesheet layout
- Compositing: skins + accessories layered in z-order, cached as single textures
- Shared types ensure client/server protocol agreement

Step 3: Commit

git add README.md CLAUDE.md
git commit -m "docs: add README and CLAUDE.md"

Task Summary

# Task Depends On
1 Project Scaffolding
2 Shared Types and Constants 1
3 ECS Core (with tests) 2
4 World Map and Pathfinding (with tests) 2
5 Server Systems (with tests) 3, 4
6 Appearance Generation (with tests) 2
7 Server Game Loop and NPC Spawning 5, 6
8 Server Networking (Socket.io) 7
9 Client Entry Point and Boot Scene 2
10 Character Compositing 9
11 Game Scene (Rendering, Camera, Input) 10, 8
12 Integration Test 11
13 Final Polish and README 12

Parallelizable: Tasks 3+4 can run in parallel. Tasks 6+9 can run in parallel (after 2). Task 10 can start once 9 is done, independent of server tasks.