Give every agent a distinct procedural pixel identity

Replace the single cloned character with identity-derived sprites: skin
tone, hair style and color, outfit (tee/hoodie/blazer), shirt palette, and
glasses all hash off the agent id, drawn with facing-aware variants, a walk
cycle, and carried-item support for later behaviors. The CEO always wears
the blazer. Appearance determinism and population variety are unit-tested.
This commit is contained in:
ZacharyZhang-NY committed 2026-07-06 21:44:04 -04:00
1 parent cab4e4ba33
commit e6532713b5
5 files changed
+338 -46

No files matched your search

@@ -76,6 +76,10 @@ export function OfficePage({ snapshot }: PageProps) {
desk: agent.isCeo ? sim.plan.ceoDesk : sim.plan.desks[agent.deskIndex!].bounds,
isCeo: agent.isCeo,
animSeed: hashString(agent.input.id) % 100,
appearance: agent.appearance,
facing: agent.facing,
walkPhase: agent.walkPhase,
carrying: agent.carrying,
}));
renderOffice(ctx, viewW, viewH, sim.plan, camera, people, now);
};
@@ -1,5 +1,6 @@
import type { AgentActivity, Camera, FloorPlan, Rect } from "./office-types";
import { deskScreenRect, drawWorld } from "./scene-renderer";
import { drawPerson, type Carrying, type Facing, type SpriteAppearance } from "./sprites";
export interface RenderPerson {
id: string;
@@ -10,6 +11,10 @@ export interface RenderPerson {
desk: Rect | null;
isCeo: boolean;
animSeed: number;
appearance: SpriteAppearance;
facing: Facing;
walkPhase: number;
carrying: Carrying;
}
const ACTIVITY_DOT: Partial<Record<AgentActivity, string>> = {
@@ -18,9 +23,6 @@ const ACTIVITY_DOT: Partial<Record<AgentActivity, string>> = {
busy: "#f59e0b",
};
const FACE_TONES = ["#f3c99f", "#e8b184", "#d29b6e", "#b57e4f", "#95603a", "#7c4d2f"];
const SHIRT_COLORS = ["#5b8dd9", "#d95b7a", "#5bbf6f", "#d9a65b", "#9b6fd9", "#5bc4c4", "#d97b5b", "#7fd95b"];
export function hashString(value: string): number {
let hash = 0;
for (let i = 0; i < value.length; i++) hash = (hash * 31 + value.charCodeAt(i)) | 0;
@@ -62,54 +64,29 @@ export function renderOffice(
}
const ordered = [...people].sort((a, b) => a.y - b.y);
for (const person of ordered) drawPerson(ctx, person, timeMs);
for (const person of ordered) {
const working = (person.activity === "active" || person.activity === "thinking") && person.walkPhase === 0;
drawPerson(ctx, person.x, person.y, person.appearance, {
facing: person.facing,
walkPhase: person.walkPhase,
working,
carrying: person.carrying,
timeMs,
animSeed: person.animSeed,
}, person.isCeo);
const dot = ACTIVITY_DOT[person.activity];
if (dot) {
ctx.fillStyle = dot;
ctx.beginPath();
ctx.arc(Math.round(person.x) + 10, Math.round(person.y) - 36, 3, 0, Math.PI * 2);
ctx.fill();
}
}
for (const person of ordered) drawNameLabel(ctx, person);
ctx.restore();
}
function drawPerson(ctx: CanvasRenderingContext2D, person: RenderPerson, timeMs: number): void {
const x = Math.round(person.x);
const working = person.activity === "active" || person.activity === "thinking";
const bob = working ? Math.sin(timeMs * 0.003 + person.animSeed) * 1.2 : 0;
const y = Math.round(person.y + bob);
const face = FACE_TONES[hashString(person.id) % FACE_TONES.length];
const shirt = person.isCeo ? "#37415c" : SHIRT_COLORS[hashString(person.id + person.name) % SHIRT_COLORS.length];
ctx.fillStyle = "rgba(20,16,12,0.25)";
ctx.fillRect(x - 8, y + 2, 16, 4);
ctx.fillStyle = "#3a3f47";
ctx.fillRect(x - 5, y - 7, 4, 8);
ctx.fillRect(x + 1, y - 7, 4, 8);
ctx.fillStyle = shirt;
ctx.fillRect(x - 8, y - 20, 16, 13);
ctx.fillStyle = face;
ctx.fillRect(x - 10, y - 18, 3, 7);
ctx.fillRect(x + 7, y - 18, 3, 7);
ctx.fillStyle = face;
ctx.fillRect(x - 7, y - 32, 14, 12);
ctx.fillStyle = "#2c2320";
ctx.fillRect(x - 8, y - 34, 16, 5);
ctx.fillRect(x - 8, y - 31, 3, 4);
ctx.fillRect(x + 5, y - 31, 3, 4);
ctx.fillStyle = "#1d1d22";
ctx.fillRect(x - 4, y - 27, 2, 2);
ctx.fillRect(x + 2, y - 27, 2, 2);
const dot = ACTIVITY_DOT[person.activity];
if (dot) {
ctx.fillStyle = dot;
ctx.beginPath();
ctx.arc(x + 10, y - 36, 3, 0, Math.PI * 2);
ctx.fill();
}
}
function drawNameLabel(ctx: CanvasRenderingContext2D, person: RenderPerson): void {
ctx.font = "600 9px -apple-system, 'Segoe UI', sans-serif";
const width = Math.ceil(ctx.measureText(person.name).width) + 10;
@@ -1,5 +1,6 @@
import { buildFloorPlan } from "./floor-plan";
import type { Camera, FloorPlan, OfficeAgentInput, Vec } from "./office-types";
import { appearanceFor, type Carrying, type Facing, type SpriteAppearance } from "./sprites";
export interface AgentSim {
input: OfficeAgentInput;
@@ -8,6 +9,10 @@ export interface AgentSim {
seat: Vec;
deskIndex: number | null;
isCeo: boolean;
appearance: SpriteAppearance;
facing: Facing;
walkPhase: number;
carrying: Carrying;
}
/**
@@ -51,6 +56,10 @@ export class OfficeSimulation {
seat,
deskIndex,
isCeo,
appearance: prev?.appearance ?? appearanceFor(input.id, isCeo),
facing: prev?.facing ?? "south",
walkPhase: prev?.walkPhase ?? 0,
carrying: prev?.carrying ?? null,
};
});
}
@@ -0,0 +1,272 @@
/**
* Deterministic pixel people. Every agent gets a stable appearance derived
* from identity (skin tone, hair style and color, outfit, glasses), drawn
* procedurally with facing-aware variants and a two-frame walk cycle, so no
* two colleagues read as clones and nobody ships binary sprite sheets.
*/
export type Facing = "north" | "south" | "east" | "west";
export type HairStyle = "buzz" | "short" | "bob" | "long" | "ponytail" | "curly" | "bald";
export type Outfit = "tee" | "hoodie" | "blazer";
export type Carrying = "coffee" | "snack" | null;
export interface SpriteAppearance {
skin: string;
hairStyle: HairStyle;
hair: string;
outfit: Outfit;
shirt: string;
accent: string;
glasses: boolean;
}
export interface PersonPose {
facing: Facing;
/** Radians accumulated while walking; 0 means standing still. */
walkPhase: number;
/** Seated work bob (used at desks). */
working: boolean;
carrying: Carrying;
timeMs: number;
animSeed: number;
}
const SKIN_TONES = ["#f3cfa6", "#eab88a", "#d9a06c", "#b97f4e", "#96613a", "#7a4c2d"];
const HAIR_COLORS = ["#241f1c", "#3c2e22", "#59422c", "#7a512f", "#a8783d", "#c9a05a", "#8d8d92", "#7c3626"];
const HAIR_STYLES: HairStyle[] = ["buzz", "short", "bob", "long", "ponytail", "curly", "bald"];
const SHIRT_COLORS = [
"#5b8dd9", "#d95b7a", "#54b06a", "#d9a65b", "#9b6fd9",
"#4fb8b8", "#d97b5b", "#7ba95a", "#c4566f", "#6a7dd9",
];
const ACCENT_COLORS = ["#e5c65a", "#d95b5b", "#5bd9c0", "#c9c9d4", "#8ad95b"];
const BLAZER = "#37415c";
const BLAZER_CEO = "#2c3448";
const PANTS = "#3a3f47";
const SHOES = "#26292e";
export function hashIdentity(value: string): number {
let hash = 2166136261;
for (let i = 0; i < value.length; i++) {
hash ^= value.charCodeAt(i);
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
}
function pick<T>(values: T[], hash: number, salt: number): T {
return values[(hash >>> salt) % values.length];
}
export function appearanceFor(id: string, isCeo: boolean): SpriteAppearance {
const hash = hashIdentity(id);
return {
skin: pick(SKIN_TONES, hash, 0),
hairStyle: isCeo ? "short" : pick(HAIR_STYLES, hash, 3),
hair: pick(HAIR_COLORS, hash, 7),
outfit: isCeo ? "blazer" : pick<Outfit>(["tee", "tee", "hoodie", "hoodie", "tee", "blazer"], hash, 11),
shirt: pick(SHIRT_COLORS, hash, 13),
accent: pick(ACCENT_COLORS, hash, 17),
glasses: (hash >>> 5) % 100 < 28,
};
}
/** Draws a person anchored at foot-center (x, y). Total height ~34px. */
export function drawPerson(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
appearance: SpriteAppearance,
pose: PersonPose,
isCeo: boolean,
): void {
const px = Math.round(x);
const walking = pose.walkPhase !== 0;
const stride = walking ? Math.sin(pose.walkPhase) * 2.5 : 0;
const bob = pose.working
? Math.sin(pose.timeMs * 0.003 + pose.animSeed) * 1.2
: walking ? Math.abs(Math.sin(pose.walkPhase)) * -1 : 0;
const py = Math.round(y + bob);
ctx.fillStyle = "rgba(24,18,12,0.28)";
ctx.fillRect(px - 8, Math.round(y) + 1, 16, 4);
drawLegs(ctx, px, py, stride);
drawTorso(ctx, px, py, appearance, pose, isCeo);
drawHead(ctx, px, py, appearance, pose.facing);
if (pose.carrying) drawCarriedItem(ctx, px, py, pose.carrying, pose.facing);
}
function drawLegs(ctx: CanvasRenderingContext2D, x: number, y: number, stride: number): void {
const left = Math.round(stride);
const right = Math.round(-stride);
ctx.fillStyle = PANTS;
ctx.fillRect(x - 5, y - 8 + Math.max(0, -left), 4, 8 - Math.abs(Math.min(left, 0)));
ctx.fillRect(x + 1, y - 8 + Math.max(0, -right), 4, 8 - Math.abs(Math.min(right, 0)));
ctx.fillStyle = SHOES;
ctx.fillRect(x - 6, y - 2 + (left > 0 ? -left : 0), 5, 3);
ctx.fillRect(x + 1, y - 2 + (right > 0 ? -right : 0), 5, 3);
}
function drawTorso(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
appearance: SpriteAppearance,
pose: PersonPose,
isCeo: boolean,
): void {
const top = y - 20;
if (appearance.outfit === "blazer") {
ctx.fillStyle = isCeo ? BLAZER_CEO : BLAZER;
ctx.fillRect(x - 8, top, 16, 12);
if (pose.facing !== "north") {
ctx.fillStyle = "#e8e6df";
ctx.fillRect(x - 2, top, 4, 7);
ctx.fillStyle = appearance.accent;
ctx.fillRect(x - 1, top + 1, 2, 6);
}
ctx.fillStyle = isCeo ? BLAZER_CEO : BLAZER;
ctx.fillRect(x - 10, top + 1, 2, 9);
ctx.fillRect(x + 8, top + 1, 2, 9);
ctx.fillStyle = appearance.skin;
ctx.fillRect(x - 10, top + 10, 2, 2);
ctx.fillRect(x + 8, top + 10, 2, 2);
return;
}
if (appearance.outfit === "hoodie") {
ctx.fillStyle = appearance.shirt;
ctx.fillRect(x - 8, top, 16, 12);
ctx.fillRect(x - 10, top + 1, 2, 9);
ctx.fillRect(x + 8, top + 1, 2, 9);
ctx.fillStyle = shade(appearance.shirt);
ctx.fillRect(x - 8, top, 16, 3);
if (pose.facing !== "north") ctx.fillRect(x - 4, top + 7, 8, 4);
ctx.fillStyle = appearance.skin;
ctx.fillRect(x - 10, top + 10, 2, 2);
ctx.fillRect(x + 8, top + 10, 2, 2);
return;
}
ctx.fillStyle = appearance.shirt;
ctx.fillRect(x - 8, top, 16, 12);
ctx.fillRect(x - 10, top + 1, 2, 4);
ctx.fillRect(x + 8, top + 1, 2, 4);
ctx.fillStyle = appearance.skin;
ctx.fillRect(x - 10, top + 5, 2, 7);
ctx.fillRect(x + 8, top + 5, 2, 7);
}
function drawHead(ctx: CanvasRenderingContext2D, x: number, y: number, appearance: SpriteAppearance, facing: Facing): void {
const top = y - 33;
ctx.fillStyle = appearance.skin;
ctx.fillRect(x - 7, top, 14, 13);
if (facing === "north") {
if (appearance.hairStyle !== "bald") {
ctx.fillStyle = appearance.hair;
ctx.fillRect(x - 7, top, 14, 9);
}
drawHairEdges(ctx, x, y, appearance, facing);
return;
}
drawHairEdges(ctx, x, y, appearance, facing);
ctx.fillStyle = "#23232a";
if (facing === "south") {
ctx.fillRect(x - 4, top + 6, 2, 2);
ctx.fillRect(x + 2, top + 6, 2, 2);
} else if (facing === "east") {
ctx.fillRect(x + 3, top + 6, 2, 2);
} else {
ctx.fillRect(x - 5, top + 6, 2, 2);
}
if (appearance.glasses) drawGlasses(ctx, x, top, facing);
}
function drawHairEdges(ctx: CanvasRenderingContext2D, x: number, y: number, appearance: SpriteAppearance, facing: Facing): void {
const top = y - 33;
if (appearance.hairStyle === "bald") return;
ctx.fillStyle = appearance.hair;
switch (appearance.hairStyle) {
case "buzz":
ctx.fillRect(x - 7, top, 14, 3);
break;
case "short":
ctx.fillRect(x - 7, top, 14, 4);
ctx.fillRect(x - 7, top + 4, 2, 3);
ctx.fillRect(x + 5, top + 4, 2, 3);
break;
case "bob":
ctx.fillRect(x - 7, top, 14, 4);
ctx.fillRect(x - 8, top + 2, 2, 9);
ctx.fillRect(x + 6, top + 2, 2, 9);
break;
case "long":
ctx.fillRect(x - 7, top, 14, 4);
ctx.fillRect(x - 9, top + 2, 3, 15);
ctx.fillRect(x + 6, top + 2, 3, 15);
break;
case "ponytail":
ctx.fillRect(x - 7, top, 14, 4);
if (facing === "north") ctx.fillRect(x - 2, top + 4, 4, 10);
else if (facing === "east") ctx.fillRect(x - 9, top + 3, 3, 8);
else if (facing === "west") ctx.fillRect(x + 6, top + 3, 3, 8);
else ctx.fillRect(x + 6, top + 1, 2, 5);
break;
case "curly":
ctx.fillRect(x - 7, top, 14, 4);
for (let i = 0; i < 7; i++) ctx.fillRect(x - 7 + i * 2, top + 3 + (i % 2), 2, 2);
break;
}
}
function drawGlasses(ctx: CanvasRenderingContext2D, x: number, headTop: number, facing: Facing): void {
ctx.fillStyle = "#1c1c22";
if (facing === "south") {
ctx.fillRect(x - 5, headTop + 5, 4, 1);
ctx.fillRect(x + 1, headTop + 5, 4, 1);
ctx.fillRect(x - 5, headTop + 8, 4, 1);
ctx.fillRect(x + 1, headTop + 8, 4, 1);
ctx.fillRect(x - 5, headTop + 5, 1, 4);
ctx.fillRect(x - 2, headTop + 5, 1, 4);
ctx.fillRect(x + 1, headTop + 5, 1, 4);
ctx.fillRect(x + 4, headTop + 5, 1, 4);
ctx.fillRect(x - 1, headTop + 6, 2, 1);
} else if (facing === "east") {
ctx.fillRect(x + 2, headTop + 5, 4, 1);
ctx.fillRect(x + 2, headTop + 8, 4, 1);
ctx.fillRect(x + 2, headTop + 5, 1, 4);
ctx.fillRect(x + 5, headTop + 5, 1, 4);
} else if (facing === "west") {
ctx.fillRect(x - 6, headTop + 5, 4, 1);
ctx.fillRect(x - 6, headTop + 8, 4, 1);
ctx.fillRect(x - 6, headTop + 5, 1, 4);
ctx.fillRect(x - 3, headTop + 5, 1, 4);
}
}
function drawCarriedItem(ctx: CanvasRenderingContext2D, x: number, y: number, item: Carrying, facing: Facing): void {
const side = facing === "west" ? -12 : 10;
const ix = x + side;
const iy = y - 14;
if (item === "coffee") {
ctx.fillStyle = "#f0ece2";
ctx.fillRect(ix, iy, 4, 5);
ctx.fillStyle = "#6b4a2f";
ctx.fillRect(ix, iy, 4, 1);
ctx.fillStyle = "rgba(240,240,245,0.7)";
ctx.fillRect(ix + 1, iy - 3, 1, 2);
} else {
ctx.fillStyle = "#c9553e";
ctx.fillRect(ix, iy, 5, 4);
ctx.fillStyle = "#e8b84a";
ctx.fillRect(ix + 1, iy + 1, 3, 2);
}
}
function shade(hex: string): string {
const value = parseInt(hex.slice(1), 16);
const r = Math.max(0, (value >> 16) - 36);
const g = Math.max(0, ((value >> 8) & 0xff) - 36);
const b = Math.max(0, (value & 0xff) - 36);
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, "0")}`;
}
@@ -0,0 +1,30 @@
import { describe, expect, test } from "vitest";
import { appearanceFor } from "../../src/renderer/src/pages/office/sprites";
describe("office sprite appearances", () => {
test("identity is deterministic", () => {
const first = appearanceFor("agent-42", false);
const second = appearanceFor("agent-42", false);
expect(second).toEqual(first);
});
test("a team of agents does not look like clones", () => {
const ids = Array.from({ length: 40 }, (_, i) => `agent-${i}-${i * 7919}`);
const looks = ids.map((id) => appearanceFor(id, false));
const skins = new Set(looks.map((look) => look.skin));
const hairStyles = new Set(looks.map((look) => look.hairStyle));
const hairColors = new Set(looks.map((look) => look.hair));
const shirts = new Set(looks.map((look) => look.shirt));
expect(skins.size).toBeGreaterThanOrEqual(4);
expect(hairStyles.size).toBeGreaterThanOrEqual(5);
expect(hairColors.size).toBeGreaterThanOrEqual(5);
expect(shirts.size).toBeGreaterThanOrEqual(6);
expect(looks.some((look) => look.glasses)).toBe(true);
expect(looks.some((look) => !look.glasses)).toBe(true);
});
test("the CEO always dresses the part", () => {
const ceo = appearanceFor("ceo-agent", true);
expect(ceo.outfit).toBe("blazer");
});
});