diff --git a/.changeset/poor-bga-500ms.md b/.changeset/poor-bga-500ms.md
new file mode 100644
index 00000000..300a7643
--- /dev/null
+++ b/.changeset/poor-bga-500ms.md
@@ -0,0 +1,12 @@
+---
+'@be-music/player': patch
+'@be-music/player-tui': patch
+'@be-music/player-web': patch
+---
+
+Shorten the default POOR / miss BGA display window from 2000 ms to 500 ms, matching real LR2.
+
+LR2 ships `500` in its `config.xml` and its changelog documents 500 ms as the
+miss-BGA default, so the previous 2-second window held the miss layer four times longer than LR2.
+`DEFAULT_POOR_BGA_DISPLAY_SECONDS` is shared by the TUI compositor and the web LR2 scene, so both
+runtimes pick up the corrected timing.
diff --git a/docs/player-spec.ja.md b/docs/player-spec.ja.md
index 826e04d4..dfdb7494 100644
--- a/docs/player-spec.ja.md
+++ b/docs/player-spec.ja.md
@@ -324,7 +324,7 @@ bmson の `judgeRank` は `#DEFEXRANK` と同じ「`100` = `NORMAL`」基準の
- `summary.poor` を加算する。
- combo を 0 に戻す。
- groove gauge を `-6` する。
-- POOR BGA を発火する。
+- POOR BGA を発火する。miss レイヤーの表示時間は 500ms(LR2 同梱 `config.xml` の既定値 `500` に一致)。
- judge/combo 表示を `POOR` に更新する。
### 空打鍵(candidate なし)— LR2 互換 空POOR
diff --git a/docs/player-spec.md b/docs/player-spec.md
index 10533390..0b27992f 100644
--- a/docs/player-spec.md
+++ b/docs/player-spec.md
@@ -315,7 +315,7 @@ When `POOR` occurs, do the following:
- Add `summary.poor`.
- Set combo back to 0.
- Set groove gauge to `-6`.
-- Fire POOR BGA.
+- Fire POOR BGA. The miss layer stays visible for 500 ms — LR2's shipped default (`500` in its `config.xml`).
- Update judge/combo display to `POOR`.
### Blank keystroke (no candidate) — LR2-compatible empty POOR
diff --git a/packages/player-tui/src/bga.test.ts b/packages/player-tui/src/bga.test.ts
index c4acf258..ae7c125f 100644
--- a/packages/player-tui/src/bga.test.ts
+++ b/packages/player-tui/src/bga.test.ts
@@ -995,6 +995,41 @@ describe('player bga', () => {
}
});
+ test('player bga: POOR overlay expires exactly at the 500 ms LR2 default window', async () => {
+ const baseDir = await mkdtemp(join(tmpdir(), 'be-music-bga-poor-window-'));
+ try {
+ await writePng(join(baseDir, 'base.png'), 256, 256, () => ({ r: 255, g: 0, b: 0, a: 255 }));
+ await writePng(join(baseDir, 'poor.png'), 256, 256, () => ({ r: 0, g: 255, b: 0, a: 255 }));
+
+ const json = createEmptyJson('bms');
+ json.metadata.bpm = 120;
+ json.resources.bmp['01'] = 'base.png';
+ json.resources.bmp['02'] = 'poor.png';
+ json.events = [
+ { measure: 0, channel: '04', position: [0, 1], value: '01' },
+ { measure: 0, channel: '06', position: [0, 1], value: '02' },
+ ];
+
+ const renderer = await createBgaAnsiRenderer(json, {
+ baseDir,
+ width: 40,
+ height: 20,
+ });
+ expect(renderer).toBeDefined();
+
+ renderer?.triggerPoor(0);
+ // The window is DEFAULT_POOR_BGA_DISPLAY_SECONDS (0.5 s, LR2's `` default) with a strict `<`
+ // expiry: the overlay is still visible just before the boundary and gone exactly at it.
+ const justBefore = parseAnsiPixels(renderer?.getAnsiLines(0.49) ?? []);
+ expect(justBefore[10]?.[20]).toEqual({ r: 0, g: 255, b: 0 });
+
+ const atBoundary = parseAnsiPixels(renderer?.getAnsiLines(0.5) ?? []);
+ expect(atBoundary[10]?.[20]).toEqual({ r: 255, g: 0, b: 0 });
+ } finally {
+ await rm(baseDir, { recursive: true, force: true });
+ }
+ });
+
test('player bga: prioritizes POOR over 04/07/0A while active', async () => {
const baseDir = await mkdtemp(join(tmpdir(), 'be-music-bga-poor-priority-'));
try {
@@ -1027,7 +1062,7 @@ describe('player bga', () => {
expect(beforePoor[10]?.[20]).toEqual({ r: 0, g: 0, b: 255 });
renderer?.triggerPoor(0);
- const duringPoor = parseAnsiPixels(renderer?.getAnsiLines(0.5) ?? []);
+ const duringPoor = parseAnsiPixels(renderer?.getAnsiLines(0.3) ?? []);
expect(duringPoor[10]?.[20]).toEqual({ r: 255, g: 255, b: 0 });
const afterPoor = parseAnsiPixels(renderer?.getAnsiLines(2.1) ?? []);
diff --git a/packages/player-web/src/scene/lr2/gameplay.ts b/packages/player-web/src/scene/lr2/gameplay.ts
index 3d9b4231..9ed51222 100644
--- a/packages/player-web/src/scene/lr2/gameplay.ts
+++ b/packages/player-web/src/scene/lr2/gameplay.ts
@@ -3955,7 +3955,7 @@ export class PixiGameplayView {
* Composites the chart's BGA into the active skin's `#DST_BGA` rectangles, or the default-family BGA rectangle when
* no external skin is loaded. Three layers stack from back to front: base (channel 04 / bmson `bga.events`), layer
* (channel 07 / 0A / bmson `layerEvents`), and a POOR override (channel 06 / `poorEvents`) that briefly replaces the
- * base while the player is in a 2-second POOR-judgement window.
+ * base while the player is inside the POOR-judgement window (`DEFAULT_POOR_BGA_DISPLAY_SECONDS`).
*
* The renderer is idempotent per frame — it tears down any existing sprites and rebuilds from the active cues, so cue
* switches show up the next frame without explicit dirty tracking.
diff --git a/packages/player/src/core/bga-timeline.ts b/packages/player/src/core/bga-timeline.ts
index fc45d426..3579dad8 100644
--- a/packages/player/src/core/bga-timeline.ts
+++ b/packages/player/src/core/bga-timeline.ts
@@ -9,10 +9,12 @@ import {
/**
* How long a POOR / miss BGA layer stays visible after a miss before the normal base/layer composite returns.
- * Shared by every renderer (TUI compositor, web LR2 scene) so the miss-layer feel is identical across runtimes —
- * change it here, not per frontend.
+ * 500 ms is LR2's shipped default (`500` in its `config.xml`; LR2 exposes it as a per-user
+ * setting, this implementation does not yet). Shared by the renderers that time the miss layer out (TUI
+ * compositor, web LR2 scene) so the miss-layer feel is identical across them — change it here, not per frontend.
+ * The beatoraja web scene instead holds its miss layer until the next non-POOR judge, as beatoraja skins expect.
*/
-export const DEFAULT_POOR_BGA_DISPLAY_SECONDS = 2;
+export const DEFAULT_POOR_BGA_DISPLAY_SECONDS = 0.5;
export interface BgaCue {
seconds: number;