diff --git a/web/src/components/MetroGame.tsx b/web/src/components/MetroGame.tsx
index a92f26d..b00fedc 100644
--- a/web/src/components/MetroGame.tsx
+++ b/web/src/components/MetroGame.tsx
@@ -6,6 +6,13 @@ import {
type HealthResponse,
type WorldResponse,
} from "../lib/api";
+import {
+ applyOfflineDecay,
+ initialPetState,
+ reducePet,
+ type PetState,
+} from "../lib/pet-state";
+import { loadPet, savePet } from "../lib/storage";
type LoadState = "loading" | "ready" | "error";
@@ -26,6 +33,8 @@ export default function MetroGame() {
const [health, setHealth] = useState(null);
const [world, setWorld] = useState(null);
const [selectedStation, setSelectedStation] = useState("A01");
+ const [companionMode, setCompanionMode] = useState(false);
+ const [pet, setPet] = useState(initialPetState);
const [error, setError] = useState(null);
useEffect(() => {
@@ -79,6 +88,22 @@ export default function MetroGame() {
};
}, [selectedStation]);
+ useEffect(() => {
+ void loadPet()
+ .then((saved) => {
+ if (saved) setPet(applyOfflineDecay(saved));
+ })
+ .catch(() => undefined);
+ }, []);
+
+ function care(action: Parameters[1]) {
+ setPet((current) => {
+ const next = reducePet(current, action);
+ void savePet(next);
+ return next;
+ });
+ }
+
function retry() {
window.location.reload();
}
@@ -151,6 +176,52 @@ export default function MetroGame() {
Something small is tucked away near {selectedStation}. It will wake
when the first station has a name.
+
+ {companionMode && (
+
+
+ {pet.growthStage === "egg" ? "( o )" : "^o^"}
+
+
+ {pet.name} is near {pet.homeStation}.
+
+
+ Hunger
+
+
+
+ Energy
+
+
+
+ Happy
+
+
+
+
+
+
+
+
+
+
+ )}
{state === "loading" &&
Checking the local signal...
}
{state === "ready" && (
diff --git a/web/src/lib/pet-state.test.ts b/web/src/lib/pet-state.test.ts
new file mode 100644
index 0000000..f9e6f05
--- /dev/null
+++ b/web/src/lib/pet-state.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ applyOfflineDecay,
+ initialPetState,
+ reducePet,
+ type PetState,
+} from "./pet-state";
+
+describe("pet state", () => {
+ it("hatches and responds to care", () => {
+ const hatched = reducePet(initialPetState, { type: "hatch" });
+ const fed = reducePet(hatched, { type: "feed" });
+ expect(fed.growthStage).toBe("baby");
+ expect(fed.hunger).toBe(7);
+ });
+
+ it("keeps needs within the seven-point range", () => {
+ const full = reducePet(initialPetState, { type: "feed" });
+ const empty = Array.from({ length: 20 }).reduce
(
+ (state) => reducePet(state, { type: "play" }),
+ full,
+ );
+ expect(empty.energy).toBe(0);
+ expect(empty.curiosity).toBe(7);
+ });
+
+ it("caps offline decay", () => {
+ const lastCareAt = new Date("2026-08-01T00:00:00Z").toISOString();
+ const state = { ...initialPetState, lastCareAt };
+ const decayed = applyOfflineDecay(
+ state,
+ Date.parse("2026-08-20T00:00:00Z"),
+ );
+ expect(decayed.hunger).toBe(2);
+ expect(decayed.energy).toBe(2);
+ });
+});
diff --git a/web/src/lib/pet-state.ts b/web/src/lib/pet-state.ts
new file mode 100644
index 0000000..f7764b1
--- /dev/null
+++ b/web/src/lib/pet-state.ts
@@ -0,0 +1,79 @@
+export interface PetState {
+ version: 1;
+ species: "trackling";
+ name: string;
+ growthStage: "egg" | "baby";
+ homeStation: string;
+ hunger: number;
+ energy: number;
+ happiness: number;
+ curiosity: number;
+ lastCareAt: string;
+}
+
+export type PetAction =
+ | { type: "hatch" }
+ | { type: "feed" }
+ | { type: "pet" }
+ | { type: "rest" }
+ | { type: "play" }
+ | { type: "setHomeStation"; station: string };
+
+export const initialPetState: PetState = {
+ version: 1,
+ species: "trackling",
+ name: "Pip",
+ growthStage: "egg",
+ homeStation: "A01",
+ hunger: 5,
+ energy: 5,
+ happiness: 4,
+ curiosity: 1,
+ lastCareAt: new Date(0).toISOString(),
+};
+
+function change(value: number, amount: number) {
+ return Math.max(0, Math.min(7, value + amount));
+}
+
+export function reducePet(state: PetState, action: PetAction): PetState {
+ const lastCareAt = new Date().toISOString();
+ switch (action.type) {
+ case "hatch":
+ return { ...state, growthStage: "baby", lastCareAt };
+ case "feed":
+ return {
+ ...state,
+ hunger: change(state.hunger, 2),
+ energy: change(state.energy, 1),
+ lastCareAt,
+ };
+ case "pet":
+ return { ...state, happiness: change(state.happiness, 2), lastCareAt };
+ case "rest":
+ return { ...state, energy: change(state.energy, 3), lastCareAt };
+ case "play":
+ return {
+ ...state,
+ energy: change(state.energy, -1),
+ happiness: change(state.happiness, 1),
+ curiosity: change(state.curiosity, 1),
+ lastCareAt,
+ };
+ case "setHomeStation":
+ return { ...state, homeStation: action.station, lastCareAt };
+ }
+}
+
+export function applyOfflineDecay(state: PetState, now = Date.now()): PetState {
+ const elapsedHours =
+ Math.max(0, now - Date.parse(state.lastCareAt)) / 3_600_000;
+ const ticks = Math.min(3, Math.floor(elapsedHours));
+ if (ticks === 0) return state;
+ return {
+ ...state,
+ hunger: change(state.hunger, -ticks),
+ energy: change(state.energy, -ticks),
+ happiness: change(state.happiness, -Math.floor(ticks / 2)),
+ };
+}
diff --git a/web/src/lib/storage.ts b/web/src/lib/storage.ts
new file mode 100644
index 0000000..8d921a5
--- /dev/null
+++ b/web/src/lib/storage.ts
@@ -0,0 +1,42 @@
+import type { PetState } from "./pet-state";
+
+const databaseName = "trackling";
+const storeName = "state";
+const stateKey = "pet";
+
+function openDatabase(): Promise {
+ return new Promise((resolve, reject) => {
+ const request = indexedDB.open(databaseName, 1);
+ request.onupgradeneeded = () => request.result.createObjectStore(storeName);
+ request.onsuccess = () => resolve(request.result);
+ request.onerror = () =>
+ reject(request.error ?? new Error("Unable to open pet storage"));
+ });
+}
+
+export async function loadPet(): Promise {
+ const database = await openDatabase();
+ return new Promise((resolve, reject) => {
+ const request = database
+ .transaction(storeName, "readonly")
+ .objectStore(storeName)
+ .get(stateKey);
+ request.onsuccess = () =>
+ resolve((request.result as PetState | undefined) ?? null);
+ request.onerror = () =>
+ reject(request.error ?? new Error("Unable to read pet storage"));
+ });
+}
+
+export async function savePet(state: PetState): Promise {
+ const database = await openDatabase();
+ return new Promise((resolve, reject) => {
+ const request = database
+ .transaction(storeName, "readwrite")
+ .objectStore(storeName)
+ .put(state, stateKey);
+ request.onsuccess = () => resolve();
+ request.onerror = () =>
+ reject(request.error ?? new Error("Unable to save pet storage"));
+ });
+}
diff --git a/web/src/styles/global.css b/web/src/styles/global.css
index 4ee5db4..da59683 100644
--- a/web/src/styles/global.css
+++ b/web/src/styles/global.css
@@ -280,6 +280,66 @@ h2 {
background: #f0a35b;
color: #101827;
}
+.companion-toggle {
+ width: 100%;
+ padding: 11px 14px;
+ border: 1px solid #f0a35b;
+ color: #101827;
+ background: #f0a35b;
+ cursor: pointer;
+}
+.companion-box {
+ margin-top: 18px;
+ padding: 16px;
+ border: 1px solid #3c5261;
+ background: #0d1724;
+}
+.companion-face {
+ margin-bottom: 12px;
+ color: #f0a35b;
+ font:
+ 1.5rem ui-monospace,
+ monospace;
+ text-align: center;
+}
+.companion-box p {
+ margin-bottom: 14px;
+ color: #c6d0d3;
+ font-size: 0.9rem;
+}
+.need-row {
+ display: grid;
+ grid-template-columns: 62px 1fr;
+ gap: 10px;
+ align-items: center;
+ margin: 7px 0;
+ color: #9eafb7;
+ font:
+ 0.7rem ui-monospace,
+ monospace;
+ text-transform: uppercase;
+}
+.need-row meter {
+ width: 100%;
+ height: 8px;
+ accent-color: #f0a35b;
+}
+.care-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-top: 16px;
+}
+.care-actions button {
+ padding: 6px 8px;
+ border: 1px solid #3c5261;
+ color: #f5ebd7;
+ background: transparent;
+ cursor: pointer;
+}
+.care-actions button:hover {
+ border-color: #f0a35b;
+}
.fine-print {
margin-bottom: 0;
font-size: 0.75rem;