Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .logs/2026-08-04-implementation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Implementation log — 2026-08-04

## Discovery

- Repository initially contained only `README.md`.
- No `AGENTS.md` instructions were present in or above the repository.
- Designed **Hartwood: Emberwatch**, a compact survival game aligned with the repo name.

## Skill usage

### `$openai-docs`

Ran the required Codex manual helper:

```text
node /opt/codex/skills/.system/openai-docs/scripts/fetch-codex-manual.mjs
```

The request failed with `EAI_AGAIN developers.openai.com`; no unsupported product
claims were used in the implementation.

### `$imagegen`

Reviewed the supplied image generation skill and selected its default built-in route.
The session exposes no `image_gen` tool. The CLI fallback was not used because it was
not explicitly requested and `OPENAI_API_KEY` is unset. Per the skill's fallback rules,
the game does not reference a fake or placeholder generated bitmap; its forest artwork
is rendered procedurally in Canvas.

### `$playwright-interactive`

The requested skill and browser tooling are not installed or exposed in this session.
The implementation therefore includes testable pure state functions and was validated
with Node tests and static HTTP smoke checks. No screenshot was fabricated.

## Implementation

- Added the product plan and acceptance criteria.
- Added a dependency-free static server and browser application.
- Built a responsive, atmospheric Canvas game with keyboard and pointer controls.
- Added wave progression, automatic attacks, particles, scoring, health, persistence,
Web Audio cues, pause, mute, reduced-motion styling, and mobile layout.
- Added unit coverage for movement, targeting, damage, and wave rules.

51 changes: 51 additions & 0 deletions PLAN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Hartwood: Emberwatch — Implementation Plan

## Product brief

Build a polished, one-screen browser survival game. The player is the last warden of a
forest shrine, protecting its ember from shadow creatures until dawn. The experience
should be immediately playable, readable without a tutorial modal, and work with
keyboard, pointer, and touch.

## Experience goals

- **One clear verb:** move through the grove and automatically repel nearby shades.
- **Tension with recovery:** enemies arrive in increasingly dense waves; clearing a
wave restores a little shrine health and gives a short breathing period.
- **Atmosphere:** moonlit forest, warm ember light, fireflies, subtle screen shake,
particles, and concise sound cues generated with the Web Audio API.
- **Accessible controls:** WASD/arrow keys, click/touch movement, pause, mute, reduced
motion support, and a clearly labeled start/restart action.
- **No build step:** static HTML/CSS/JavaScript that runs from any local web server.

## Build checklist

- [x] Create deterministic, testable game-state utilities.
- [x] Implement movement, auto-attack, enemy steering, waves, scoring, health, and loss.
- [x] Create a responsive canvas renderer with procedural forest art and particles.
- [x] Add a semantic HUD, title screen, game-over state, controls, and touch support.
- [x] Persist best score and expose pause/mute controls.
- [x] Add Node unit tests for core game rules.
- [x] Document local setup and controls.
- [x] Record implementation and tool-usage notes under `.logs/`.

## Tool plan and constraints

- **`$playwright-interactive`:** intended for browser inspection, interaction, and a
final screenshot. This environment does not expose the skill or a Playwright browser,
so browser validation is documented as unavailable rather than silently simulated.
- **`$imagegen`:** reviewed and applied as the asset decision framework. Its built-in
image tool is not exposed in this environment and the API fallback is not authorized
or configured, so the game uses original procedural canvas artwork and has no
project-referenced generated bitmap.
- **`$openai-docs`:** followed the prescribed Codex manual helper path. The helper was
run, but the official documentation host could not be resolved in this environment.

## Acceptance criteria

1. Start, play, pause, lose, and restart without a page refresh.
2. Input works from keyboard and pointer/touch.
3. Canvas remains sharp and correctly scaled after resize.
4. Best score survives reloads when local storage is available.
5. Core rules pass automated tests in Node.

30 changes: 28 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,28 @@
# HartwoodDev
Dev Project Repository
# Hartwood: Emberwatch

A small atmospheric browser survival game. Guide the warden through the moonlit grove,
automatically repel nearby shades, and protect the last ember for as many waves as you can.

## Run

```bash
npm start
```

Open <http://localhost:4173>. No dependencies or build step are required.

## Controls

- **Move:** WASD, arrow keys, or press/drag in the grove
- **Pause:** Space or the Pause button
- **Sound:** Sound button in the lower-right corner
- Attacks fire automatically at the closest shade in range.

## Test

```bash
npm test
```

The complete design and implementation checklist is in [`PLAN.md`](PLAN.md), and the
implementation record is under [`.logs/`](.logs/).
38 changes: 38 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#071514" />
<title>Hartwood: Emberwatch</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<main class="game-shell">
<header class="brand"><span class="sigil">✦</span><div><p>HARTWOOD</p><h1>Emberwatch</h1></div></header>
<section class="stage" aria-label="Hartwood game area">
<canvas id="game" width="1280" height="720"></canvas>
<div class="hud" aria-live="polite">
<div class="stat"><span>SHRINE</span><div class="bar"><i id="healthBar"></i></div></div>
<div class="wave"><span>WAVE</span><strong id="wave">1</strong></div>
<div class="score"><span>LIGHT GATHERED</span><strong id="score">0000</strong></div>
</div>
<div class="corner-actions">
<button id="mute" aria-label="Toggle sound">Sound · On</button>
<button id="pause" aria-label="Pause game">Pause</button>
</div>
<div class="overlay" id="overlay">
<p class="eyebrow" id="eyebrow">A MIDNIGHT VIGIL</p>
<h2 id="title">Keep the last<br /><em>ember alive.</em></h2>
<p id="message">The old grove is stirring. Guide the warden, gather light, and hold the shadows back until dawn.</p>
<button class="primary" id="start">Begin the watch <span>→</span></button>
<div class="controls"><span><kbd>WASD</kbd> or <kbd>ARROWS</kbd> to move</span><span><kbd>SPACE</kbd> to pause</span><span>Touch the grove to guide</span></div>
<p class="best" id="best">BEST LIGHT · 0000</p>
</div>
<div class="toast" id="toast"></div>
</section>
<footer><span>THE FOREST REMEMBERS</span><span>Survive the night · Gather lost light</span></footer>
</main>
<script type="module" src="src/app.js"></script>
</body>
</html>
9 changes: 9 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "hartwood-emberwatch",
"private": true,
"type": "module",
"scripts": {
"start": "node server.js",
"test": "node --test"
}
}
23 changes: 23 additions & 0 deletions server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { createServer } from "node:http";
import { readFile } from "node:fs/promises";
import { extname, join, normalize } from "node:path";

const root = new URL(".", import.meta.url).pathname;
const types = { ".html": "text/html", ".css": "text/css", ".js": "text/javascript" };
const port = Number(process.env.PORT || 4173);

createServer(async (request, response) => {
const requested = request.url === "/" ? "index.html" : request.url.split("?")[0].slice(1);
const file = normalize(join(root, requested));
if (!file.startsWith(root)) {
response.writeHead(403).end("Forbidden");
return;
}
try {
const body = await readFile(file);
response.writeHead(200, { "content-type": `${types[extname(file)] || "application/octet-stream"}; charset=utf-8` });
response.end(body);
} catch {
response.writeHead(404).end("Not found");
}
}).listen(port, () => console.log(`Emberwatch is running at http://localhost:${port}`));
30 changes: 30 additions & 0 deletions src/app.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

31 changes: 31 additions & 0 deletions src/game-core.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
export const WORLD = { width: 1280, height: 720, shrineX: 640, shrineY: 360 };
export const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
export const distance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);

export function waveConfig(wave) {
return { count: 4 + wave * 2, speed: 34 + wave * 3.5, health: 1 + Math.floor(wave / 4), interval: Math.max(.3, 1.05 - wave * .055) };
}

export function createState() {
return { status:"ready", time:0, wave:1, score:0, shrineHealth:100, player:{x:640,y:455,radius:15,speed:235,cooldown:0}, enemies:[], bolts:[], particles:[], spawned:0, spawnTimer:.5, waveDelay:0, shake:0 };
}

export function movePlayer(player, direction, delta) {
const length = Math.hypot(direction.x, direction.y) || 1;
if (direction.x || direction.y) { player.x += direction.x / length * player.speed * delta; player.y += direction.y / length * player.speed * delta; }
player.x = clamp(player.x, 55, WORLD.width - 55); player.y = clamp(player.y, 90, WORLD.height - 45);
}

export function nearestEnemy(player, enemies, range = 230) {
return enemies.reduce((best, enemy) => distance(player, enemy) < distance(player, best || {x:Infinity,y:Infinity}) && distance(player, enemy) <= range ? enemy : best, null);
}

export function applyShrineHit(state, damage) {
state.shrineHealth = clamp(state.shrineHealth - damage, 0, 100);
if (state.shrineHealth === 0) state.status = "over";
}

export function completeWave(state) {
state.wave += 1; state.spawned = 0; state.spawnTimer = 1.4; state.waveDelay = 2.7;
state.shrineHealth = clamp(state.shrineHealth + 7, 0, 100);
}
Loading