Skip to content
Draft
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
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,16 +59,22 @@ while (!windowShouldClose()) {
Use `runGame()` for code that works on both native and web:

```typescript
import { initWindow, runGame, clearBackground, drawText, Colors } from "@bloomengine/engine";
import { initWindow, runGame, clearBackground, drawText, closeWindow, Colors } from "@bloomengine/engine";

initWindow(800, 450, "My Game");

runGame((dt) => {
clearBackground(Colors.SNOW);
drawText("Hello, Bloom!", 190, 200, 20, Colors.DARKGRAY);
}, () => {
closeWindow();
});
```

Put resource disposal in the optional cleanup callback. It runs after the final
frame on native and web; code following `runGame()` runs immediately on web.
See the [game-loop contract](docs/game-loop.md) for timing and shutdown behavior.

Build for web:

```bash
Expand Down
58 changes: 58 additions & 0 deletions docs/evidence/windows-game-cleanup-v1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Shared game cleanup and canonical example startup

`runGame(updateAndDraw, cleanup)` adds an optional cleanup callback without
changing existing callback-only calls. Native invokes cleanup after the normal
loop exits. The browser scheduler defers it until an active frame ends, cancels
future frames and invokes it once. Frame failures stop scheduling and report an
error; cleanup is attempted. The [public contract](../game-loop.md) records
timing, duplicate-start, focus and fatal-process limitations.

Pong now uses this loop, disposes audio/window resources in cleanup and toggles
pause with `isKeyPressed(Key.P)`. Its actual runtime check also found an earlier
example correction was incomplete: `Colors.White` and similar names are
undefined because `Colors` exposes uppercase aliases. All 51 such references
in six canonical examples now use valid names. The example inventory checks
references against the exported palette, with invalid mixed-case names as a
negative test. This catches a failure that Perry's native linking did not.

## Local acceptance

- A fresh actual installed package simulates Jolt, captures all 16,384 expected
pixels and records exactly one cleanup call in both scene and direct-2D modes,
on Radeon DX12 and Vulkan. All four PNGs are byte-identical. Native compiles
take 270.609 and 3.390 seconds; these are test durations, not FPS.
- A separate real native callback-only probe still renders the same exact
physics frame, returns from the loop and reaches its existing post-loop
cleanup. The optional parameter does not break this older native pattern.
- Compiled Pong input replay records nine frames after staging a held P key.
The old held-key control yields `RPRPRPRPR`; the edge-triggered version yields
`RPPPPPPPP` (R=running, P=paused). The first frame precedes application of the
queued input. Both variants start and close on Radeon DX12.
- All 20 native canonical examples compile and link in 134.884 seconds. This
does not claim runtime acceptance for every example.
- The same corrected Pong source and actual engine complete the web build in
72.875 seconds, with the new scheduler copied into the assembled distribution.
The 7,829,878-byte engine WASM and generated bindings include the new bridge.
Browser rendering is not claimed by compilation and assembly.
- Nine scheduler tests and nine web-command regression tests pass. The example
gate rejects invalid palette names while accepting the public uppercase keys.

Repository contracts, the complete quality-contract component, formatting,
strict Clippy and the shared WASM compile check pass. The complete web build
also compiles the web crate and generates its new FFI export. Hosted validation
is retained with the final published evidence.

The initial Pong runtime failure and the intermediate pause-audit expectation
error remain recorded. That audit initially counted eight frames as eight held
updates; input staging makes it seven. The final replay records the full state
sequence instead of inferring it from an ambiguous last frame.

The runs use the qualified Perry 0.5.1220 source/runtime profile and local SDK
DXC on PATH. A fresh installed native project has its own Cargo outputs; the
canonical example audit reuses the native build cache. Native presentation,
packaged DXC, actual compiled-game browser startup, fixed updates, a complete
lifecycle and one-command creation remain separate requirements.

Raw diagnostics are retained under
`tools/quality/out/windows-engine-plan/starter-lifecycle/`. Test executables,
installed trees and generated HTML/WASM stay outside release-evidence archives.
44 changes: 44 additions & 0 deletions docs/game-loop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Game loop and cleanup

Call `initWindow()` and create resources before starting the game. Use
`runGame(updateAndDraw, cleanup)` for a source entry that works on native and
web. The cleanup callback is optional; existing callback-only games keep working.

The engine begins drawing, calls `updateAndDraw(dt)` and ends drawing once per
frame. Do not add another `beginDrawing()`/`endDrawing()` pair inside this
callback. Native drives a blocking platform loop. Web registers the Perry
closure and returns immediately, then drives it through animation frames.

Release owned textures, models, audio and physics objects in `cleanup`, not in
code following `runGame()`. Native calls it after the normal loop exits. Web
calls it once after a stop request and after any active frame finishes. Calling
`closeWindow()` again from cleanup is safe. A browser frame error stops further
scheduling, reports the error and attempts cleanup. A fatal native process trap
does not guarantee cleanup.

One browser game loop can be active at a time. Starting another while the first
is active reports an error. Stop and finish cleanup before starting a new loop;
cancelled callbacks from the old loop cannot run the new game's update.

## Timing and platform events

- `dt` is variable frame time in seconds. This API does not add fixed updates,
interpolation or a catch-up policy. Keep deterministic physics steps explicit.
- Pause is game policy. Pong uses `isKeyPressed(Key.P)` so holding P toggles once
per press; paddle movement continues to use held-key state.
- Focus loss does not automatically pause the game. Browsers can throttle
animation frames in background tabs, so games should choose how to handle a
large delta when returning.
- Existing native/window and browser/canvas resize handling stays in place.
- Browser frame failures stop the scheduler. Automatic device-loss recovery and
restoration of game resources are separate requirements.
- Browser page/process termination does not promise a final JavaScript callback.

Pong now uses this shared entry and cleanup instead of a blocking source loop.
Canonical examples use the palette's public uppercase names, such as
`Colors.WHITE`; the inventory rejects undefined names such as `Colors.White`.
The installed native gate verifies cleanup runs exactly once after its physics
and capture fixture. Scheduler tests cover the asynchronous ordering, stop,
failure, re-entry and stale-callback behavior without claiming browser rendering.
Actual compiled-game browser startup, a complete init/update/fixed-update/draw
lifecycle and the one-command starter remain open under #142/#74.
13 changes: 13 additions & 0 deletions docs/web-target.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,19 @@ runGame((dt) => {

On native, `runGame()` enters a blocking loop. On web, it passes the callback to the JS runtime which drives it via `requestAnimationFrame`.

An optional second callback disposes game resources after the final frame:

```typescript
runGame(updateAndDraw, () => {
unloadTexture(playerTexture);
closeWindow();
});
```

Do not put cleanup after `runGame()`; that code executes immediately on web.
`closeWindow()` during a frame requests a stop, and browser cleanup waits until
`endDrawing()` finishes. See the [game-loop contract](game-loop.md).

The traditional `while (!windowShouldClose())` pattern still works on native but is not supported on web.

## Asset Loading
Expand Down
13 changes: 10 additions & 3 deletions docs/windows-engine-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,11 +96,18 @@ audit are saved in `tools/quality/out/windows-engine-plan/plan-requirements.json
fixes Jolt directory lookup and redundant final-link metadata. A diagnostic
installed fixture simulates Jolt and renders an exact frame on DX12 and
Vulkan. The complete fresh-package checker also passes both backends with
all 16,384 pixels matching and no CMake fallback. Hosted startup checks are
pending. The [direct-frame capture correction](evidence/windows-direct-frame-capture-v1.md)
all 16,384 pixels matching and no CMake fallback. All 22 hosted Tests jobs at
#167 pass, including installed startup, with [published evidence](https://github.com/Bloom-Engine/engine/releases/tag/quality-evidence-installed-native-20260911).
The [direct-frame capture correction](evidence/windows-direct-frame-capture-v1.md)
also passes the installed physics/image fixture in both scene and direct-2D
modes on DX12 and Vulkan. A render-target regression checks capture deferral
and fresh output pixels. Browser starter
and fresh output pixels. All 22 hosted Tests jobs at #168 pass, including
both installed rendering modes. The [game-loop cleanup](game-loop.md) adds an optional
final callback on native and web and moves Pong onto the shared loop with
edge-triggered pause input. [Native cleanup and Pong replay evidence](evidence/windows-game-cleanup-v1.md)
also verifies the corrected palette names and all 20 example links. The same
Pong source completes the real web build; its browser frame remains unproven.
Browser starter
rendering, visible native presentation, shared lifecycle, general long-path
support remain open.
3. Complete the wider temporal/geometry, performance, memory, resize, and
Expand Down
12 changes: 6 additions & 6 deletions examples/dungeon-crawl/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,7 @@ while (!windowShouldClose()) {
);
// HP bar
const hpRatio = enemies[i].hp / enemies[i].maxHp;
drawRect(enemies[i].x * TILE_SIZE, enemies[i].y * TILE_SIZE - 4, Math.floor(TILE_SIZE * hpRatio), 3, Colors.Red);
drawRect(enemies[i].x * TILE_SIZE, enemies[i].y * TILE_SIZE - 4, Math.floor(TILE_SIZE * hpRatio), 3, Colors.RED);
}

// Draw player
Expand All @@ -398,9 +398,9 @@ while (!windowShouldClose()) {

// HUD
drawRect(0, 0, SCREEN_WIDTH, 35, { r: 0, g: 0, b: 0, a: 180 });
drawText("HP: " + player.hp.toString() + "/" + player.maxHp.toString(), 10, 8, 20, player.hp > player.maxHp / 3 ? Colors.Green : Colors.Red);
drawText("Floor: " + floor.toString(), 200, 8, 20, Colors.White);
drawText("Turns: " + turnCount.toString(), 350, 8, 20, Colors.LightGray);
drawText("HP: " + player.hp.toString() + "/" + player.maxHp.toString(), 10, 8, 20, player.hp > player.maxHp / 3 ? Colors.GREEN : Colors.RED);
drawText("Floor: " + floor.toString(), 200, 8, 20, Colors.WHITE);
drawText("Turns: " + turnCount.toString(), 350, 8, 20, Colors.LIGHTGRAY);

// Message log
if (messageTimer > 0) {
Expand All @@ -412,9 +412,9 @@ while (!windowShouldClose()) {
if (player.hp <= 0) {
drawRect(0, SCREEN_HEIGHT / 2 - 50, SCREEN_WIDTH, 100, { r: 0, g: 0, b: 0, a: 200 });
const deathMsg = "You have perished on floor " + floor.toString();
drawText(deathMsg, SCREEN_WIDTH / 2 - measureText(deathMsg, 24) / 2, SCREEN_HEIGHT / 2 - 20, 24, Colors.Red);
drawText(deathMsg, SCREEN_WIDTH / 2 - measureText(deathMsg, 24) / 2, SCREEN_HEIGHT / 2 - 20, 24, Colors.RED);
const restartMsg = "Press ENTER to try again";
drawText(restartMsg, SCREEN_WIDTH / 2 - measureText(restartMsg, 18) / 2, SCREEN_HEIGHT / 2 + 15, 18, Colors.LightGray);
drawText(restartMsg, SCREEN_WIDTH / 2 - measureText(restartMsg, 18) / 2, SCREEN_HEIGHT / 2 + 15, 18, Colors.LIGHTGRAY);
}

endDrawing();
Expand Down
26 changes: 13 additions & 13 deletions examples/isometric-rpg/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ function itemColor(t: number): Color {
if (t === ITEM_SHIELD) return { r: 100, g: 100, b: 200, a: 255 };
if (t === ITEM_KEY) return { r: 255, g: 220, b: 50, a: 255 };
if (t === ITEM_COIN) return { r: 255, g: 200, b: 0, a: 255 };
return Colors.White;
return Colors.WHITE;
}

function itemName(t: number): string {
Expand Down Expand Up @@ -409,7 +409,7 @@ while (!windowShouldClose()) {
// HP bar
const barW = TILE_W * camera.zoom * 0.6;
const hpRatio = npcs[i].hp / npcs[i].maxHp;
drawRect(sx - barW / 2, sy - size - 4 + TILE_H * camera.zoom * 0.3, barW * hpRatio, 3, Colors.Red);
drawRect(sx - barW / 2, sy - size - 4 + TILE_H * camera.zoom * 0.3, barW * hpRatio, 3, Colors.RED);
}

// Draw player
Expand All @@ -424,15 +424,15 @@ while (!windowShouldClose()) {

// HUD panel
drawRect(0, 0, SCREEN_WIDTH, 45, { r: 20, g: 20, b: 30, a: 220 });
drawText(player.name + " Lv." + level.toString(), 10, 5, 18, Colors.White);
drawText(player.name + " Lv." + level.toString(), 10, 5, 18, Colors.WHITE);
// HP bar
drawRect(10, 28, 120, 10, { r: 60, g: 0, b: 0, a: 255 });
drawRect(10, 28, Math.floor(120 * player.hp / player.maxHp), 10, Colors.Red);
drawText(player.hp.toString() + "/" + player.maxHp.toString(), 15, 27, 10, Colors.White);
drawRect(10, 28, Math.floor(120 * player.hp / player.maxHp), 10, Colors.RED);
drawText(player.hp.toString() + "/" + player.maxHp.toString(), 15, 27, 10, Colors.WHITE);

drawText("ATK: " + player.attack.toString(), 150, 8, 16, { r: 255, g: 150, b: 50, a: 255 });
drawText("DEF: " + player.defense.toString(), 240, 8, 16, { r: 50, g: 150, b: 255, a: 255 });
drawText("Gold: " + gold.toString(), 330, 8, 16, Colors.Yellow);
drawText("Gold: " + gold.toString(), 330, 8, 16, Colors.YELLOW);
drawText("EXP: " + exp.toString() + "/" + (level * 20).toString(), 430, 8, 16, { r: 150, g: 255, b: 150, a: 255 });

// Inventory
Expand All @@ -442,17 +442,17 @@ while (!windowShouldClose()) {
if (i > 0) invStr = invStr + ", ";
invStr = invStr + itemName(inventory[i]);
}
drawText(invStr, 550, 8, 14, Colors.LightGray);
drawText(invStr, 550, 8, 14, Colors.LIGHTGRAY);
}

// Dialogue box
if (showDialogue) {
drawRect(50, SCREEN_HEIGHT - 120, SCREEN_WIDTH - 100, 100, { r: 10, g: 10, b: 30, a: 230 });
drawRectLines(50, SCREEN_HEIGHT - 120, SCREEN_WIDTH - 100, 100, 2, Colors.White);
drawRectLines(50, SCREEN_HEIGHT - 120, SCREEN_WIDTH - 100, 100, 2, Colors.WHITE);
const npcName = npcs[dialogueNpc].name;
drawText(npcName, 70, SCREEN_HEIGHT - 110, 20, Colors.Yellow);
drawText(dialogueText, 70, SCREEN_HEIGHT - 80, 18, Colors.White);
drawText("[SPACE] to continue", 70, SCREEN_HEIGHT - 35, 14, Colors.LightGray);
drawText(npcName, 70, SCREEN_HEIGHT - 110, 20, Colors.YELLOW);
drawText(dialogueText, 70, SCREEN_HEIGHT - 80, 18, Colors.WHITE);
drawText("[SPACE] to continue", 70, SCREEN_HEIGHT - 35, 14, Colors.LIGHTGRAY);
}

// Message log
Expand All @@ -464,8 +464,8 @@ while (!windowShouldClose()) {
// Death
if (player.hp <= 0) {
drawRect(0, SCREEN_HEIGHT / 2 - 40, SCREEN_WIDTH, 80, { r: 0, g: 0, b: 0, a: 200 });
drawText("YOU DIED", SCREEN_WIDTH / 2 - measureText("YOU DIED", 50) / 2, SCREEN_HEIGHT / 2 - 30, 50, Colors.Red);
drawText("Press ENTER to respawn", SCREEN_WIDTH / 2 - measureText("Press ENTER to respawn", 18) / 2, SCREEN_HEIGHT / 2 + 25, 18, Colors.LightGray);
drawText("YOU DIED", SCREEN_WIDTH / 2 - measureText("YOU DIED", 50) / 2, SCREEN_HEIGHT / 2 - 30, 50, Colors.RED);
drawText("Press ENTER to respawn", SCREEN_WIDTH / 2 - measureText("Press ENTER to respawn", 18) / 2, SCREEN_HEIGHT / 2 + 25, 18, Colors.LIGHTGRAY);
}

// Controls hint
Expand Down
20 changes: 10 additions & 10 deletions examples/kart-racer/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,12 +305,12 @@ while (!windowShouldClose()) {

// Start/finish line
const startCp = checkpoints[0];
drawCube({ x: startCp.x, y: 0.2, z: startCp.z }, 1, 0.3, TRACK_WIDTH, Colors.White);
drawCube({ x: startCp.x, y: 0.2, z: startCp.z }, 1, 0.3, TRACK_WIDTH, Colors.WHITE);

// Checkpoint markers (small posts on track edges)
for (let i = 0; i < NUM_CHECKPOINTS; i++) {
const cp = checkpoints[i];
const color = i === 0 ? Colors.White : { r: 200, g: 200, b: 50, a: 255 };
const color = i === 0 ? Colors.WHITE : { r: 200, g: 200, b: 50, a: 255 };
drawCube({ x: cp.x, y: 1, z: cp.z - TRACK_WIDTH * 0.5 - 1 }, 0.5, 2, 0.5, color);
drawCube({ x: cp.x, y: 1, z: cp.z + TRACK_WIDTH * 0.5 + 1 }, 0.5, 2, 0.5, color);
}
Expand Down Expand Up @@ -357,21 +357,21 @@ while (!windowShouldClose()) {

// Speed
const speedKmh = Math.floor(Math.abs(player.speed) * 3.6);
drawText(speedKmh.toString() + " km/h", 10, 10, 22, Colors.White);
drawText(speedKmh.toString() + " km/h", 10, 10, 22, Colors.WHITE);

// Position
drawText(placeSuffix(playerPlace), 200, 10, 22, Colors.Yellow);
drawText(placeSuffix(playerPlace), 200, 10, 22, Colors.YELLOW);

// Lap
const lapText = "Lap " + Math.min(player.lap + 1, TOTAL_LAPS).toString() + "/" + TOTAL_LAPS.toString();
drawText(lapText, 350, 10, 22, Colors.White);
drawText(lapText, 350, 10, 22, Colors.WHITE);

// Time
drawText(formatTime(raceTime), SCREEN_WIDTH - 150, 10, 22, Colors.LightGray);
drawText(formatTime(raceTime), SCREEN_WIDTH - 150, 10, 22, Colors.LIGHTGRAY);

// Best lap
if (bestLapTime > 0) {
drawText("Best: " + formatTime(bestLapTime), SCREEN_WIDTH - 150, 35, 16, Colors.Green);
drawText("Best: " + formatTime(bestLapTime), SCREEN_WIDTH - 150, 35, 16, Colors.GREEN);
}

// Countdown
Expand All @@ -380,16 +380,16 @@ while (!windowShouldClose()) {
const countText = countNum > 0 ? countNum.toString() : "GO!";
const fontSize = 80;
drawText(countText, SCREEN_WIDTH / 2 - measureText(countText, fontSize) / 2, SCREEN_HEIGHT / 2 - 50, fontSize,
countNum <= 1 ? Colors.Green : Colors.Red);
countNum <= 1 ? Colors.GREEN : Colors.RED);
}

// Race finish
if (raceFinished) {
drawRect(0, SCREEN_HEIGHT / 2 - 60, SCREEN_WIDTH, 120, { r: 0, g: 0, b: 0, a: 200 });
const finishText = "RACE COMPLETE!";
drawText(finishText, SCREEN_WIDTH / 2 - measureText(finishText, 50) / 2, SCREEN_HEIGHT / 2 - 45, 50, Colors.Gold);
drawText(finishText, SCREEN_WIDTH / 2 - measureText(finishText, 50) / 2, SCREEN_HEIGHT / 2 - 45, 50, Colors.GOLD);
const resultText = "Finished " + placeSuffix(playerPlace) + " — Time: " + formatTime(raceTime);
drawText(resultText, SCREEN_WIDTH / 2 - measureText(resultText, 24) / 2, SCREEN_HEIGHT / 2 + 15, 24, Colors.White);
drawText(resultText, SCREEN_WIDTH / 2 - measureText(resultText, 24) / 2, SCREEN_HEIGHT / 2 + 15, 24, Colors.WHITE);
}

// Controls hint (first few seconds)
Expand Down
Loading
Loading