- 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>
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
charsfolder is inclient/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.