Skip to content
Merged
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
57 changes: 57 additions & 0 deletions frontend/src/ui/arcade/SnakeGame.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { afterEach, describe, expect, it } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import SnakeGame from "./SnakeGame";
import { CELL_CSS, EDGE_INSET } from "./snake/stage";

function mockBox(el: HTMLElement, w: number, h: number): void {
Object.defineProperty(el, "clientWidth", { configurable: true, value: w });
Object.defineProperty(el, "clientHeight", { configurable: true, value: h });
el.getBoundingClientRect = () =>
({
x: 0,
y: 0,
left: 0,
top: 0,
right: w,
bottom: h,
width: w,
height: h,
toJSON: () => ({}),
}) as DOMRect;
}

describe("SnakeGame stage fill", () => {
afterEach(() => {
cleanup();
});

it("starts a scored run at the measured stage size, not a fixed 34x18", () => {
const cols = 50;
const rows = 28;
const w = cols * CELL_CSS + 2 * EDGE_INSET;
const h = rows * CELL_CSS + 2 * EDGE_INSET;

const { container } = render(
<SnakeGame
seed={1}
paused={false}
shake={false}
onScore={() => {}}
onGameOver={() => {}}
/>,
);
const root = container.querySelector(".arcade-snake-root");
if (!(root instanceof HTMLElement)) throw new Error("snake root missing");
mockBox(root, w, h);
fireEvent(window, new Event("resize"));

fireEvent.click(screen.getByRole("button", { name: /^normal$/i }));

const canvas = container.querySelector("canvas");
if (!(canvas instanceof HTMLCanvasElement)) throw new Error("canvas missing");
expect(canvas.style.width).toBe(`${cols * CELL_CSS}px`);
expect(canvas.style.height).toBe(`${rows * CELL_CSS}px`);
expect(canvas.width).toBe(cols * CELL_CSS);
expect(canvas.height).toBe(rows * CELL_CSS);
});
});
61 changes: 20 additions & 41 deletions frontend/src/ui/arcade/SnakeGame.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { bus, type ArcadeBusEvent } from "../../game/eventBus";
import {
DEFAULT_SNAKE_HEIGHT,
DEFAULT_SNAKE_WIDTH,
initSnake,
snakeInput,
snakeFrame,
Expand All @@ -20,44 +18,29 @@ import {
renderHeading,
} from "./snake/interp";
import { renderSnake } from "./snake/render";
import {
CELL_CSS,
EDGE_INSET,
MIN_COLS,
MIN_ROWS,
boardForStage,
type SnakeBoard,
} from "./snake/stage";
import { useReducedMotion } from "../reducedMotionBridge";
import type { ArcadeGameProps } from "./gameTypes";

const MAX_DPR = 2;
/**
* FIXED cell size in CSS px (the original game's grid). Cells are NEVER scaled
* to fit the stage — that just reads as "zoomed in". The board fills the stage
* by GRID SIZE instead: more cells, same 20px each.
*/
const CELL_CSS = 20;
/** Smallest playable board if the stage is tiny. */
const MIN_COLS = 20;
const MIN_ROWS = 12;
/**
* Minimum letterbox per side. The stage surface clips with a 10px border
* radius; anything closer than ~3px to its edge gets a bite taken out of the
* corner cells, which players read as "the end cell is cut off".
*/
const EDGE_INSET = 4;
const DIFFICULTY_KEY = "arcadeSnakeDifficulty";
const DIFFICULTIES: readonly Difficulty[] = ["easy", "normal", "hard"];

/** Board size in whole cells for the current stage box. */
interface Board {
readonly cols: number;
readonly rows: number;
}

interface SnakeMotion {
readonly previous: SnakeState["body"];
readonly current: SnakeState["body"];
readonly dir: Dir;
readonly snap: boolean;
}

const INITIAL_BOARD: Board = { cols: MIN_COLS, rows: MIN_ROWS };
/** Fixed dimensions keep globally ranked Snake runs comparable. */
const SCORE_BOARD: Board = { cols: DEFAULT_SNAKE_WIDTH, rows: DEFAULT_SNAKE_HEIGHT };
const INITIAL_BOARD: SnakeBoard = { cols: MIN_COLS, rows: MIN_ROWS };

/** Reducer domain event → bus event name (glue only — no game rules). */
const BUS_FOR_EVENT: Readonly<Partial<Record<SnakeEvent, ArcadeBusEvent>>> = {
Expand Down Expand Up @@ -132,12 +115,12 @@ export default function SnakeGame({
const bestScoreRef = useRef(bestScore);
const pausedRef = useRef(paused);
/** Latest measurement of the stage; only adopted between runs. */
const measuredRef = useRef<Board>(INITIAL_BOARD);
const measuredRef = useRef<SnakeBoard>(INITIAL_BOARD);
/** Board the canvas backing store is currently sized for (mirror of state). */
const sizedRef = useRef<Board>(INITIAL_BOARD);
const sizedRef = useRef<SnakeBoard>(INITIAL_BOARD);
const phaseRef = useRef<"menu" | "play">("menu");
/** Board the canvas is currently sized for (frozen while a run is live). */
const [board, setBoard] = useState<Board>(INITIAL_BOARD);
const [board, setBoard] = useState<SnakeBoard>(INITIAL_BOARD);
const [phase, setPhase] = useState<"menu" | "play">("menu");
const [difficulty, setDifficulty] = useState<Difficulty>(readStoredDifficulty);

Expand Down Expand Up @@ -240,13 +223,8 @@ export default function SnakeGame({
const measure = useCallback(() => {
const root = rootRef.current;
if (!root) return;
const boxW = root.clientWidth - 2 * EDGE_INSET;
const boxH = root.clientHeight - 2 * EDGE_INSET;
if (boxW < 1 || boxH < 1) return;
const next: Board = {
cols: Math.max(MIN_COLS, Math.floor(boxW / CELL_CSS)),
rows: Math.max(MIN_ROWS, Math.floor(boxH / CELL_CSS)),
};
const next = boardForStage(root.clientWidth, root.clientHeight);
if (!next) return;
measuredRef.current = next;
layout();
if (phaseRef.current !== "menu") return;
Expand Down Expand Up @@ -292,13 +270,14 @@ export default function SnakeGame({
if (pausedRef.current) return;
writeStoredDifficulty(d);
setDifficulty(d);
// Scored runs always use one board size; CSS layout handles letterboxing
// on narrow/fullscreen surfaces without changing leaderboard potential.
setBoard(SCORE_BOARD);
// Fill the cabinet with whole 20px cells. Rules, speed, and scoring
// stay the same; only the playfield grows to the measured stage.
const next = measuredRef.current;
setBoard(next);
stateRef.current = initSnake(seed, {
difficulty: d,
width: SCORE_BOARD.cols,
height: SCORE_BOARD.rows,
width: next.cols,
height: next.rows,
Comment on lines +275 to +280

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep ranked Snake runs on a comparable board

Starting a run with measuredRef.current makes the collision boundary, food distribution, and maximum attainable score depend on the player's viewport/fullscreen state, while ArcadeOverlay still submits every result to the same snake-v2 leaderboard. A player can therefore resize before starting to select anything from the 20×12 minimum to a much larger field, mixing materially different gameplay conditions with existing fixed-34×18 scores; keep ranked dimensions fixed, normalize/version these scores, or make variable-board runs unranked. This also moves a gameplay decision into the renderer despite the repository's pure-rules/thin-renderer invariant.

AGENTS.md reference: AGENTS.md:L71-L74

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declining this one. The owner asked to fill the cabinet with the same 20px cells (more cells, no scale/zoom). Restoring a fixed 34x18 scored board, versioning scores, or making larger boards unranked would reverse that intent.

The reducer is still dimension-agnostic: initSnake already takes width/height. The renderer only measures how many whole 20px cells fit and passes those numbers in. Scores stay on snake-v2; the leaderboard is already client-trusted at this phase.

});
const initial = stateRef.current;
motionRef.current = {
Expand Down
34 changes: 34 additions & 0 deletions frontend/src/ui/arcade/snake/stage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import {
CELL_CSS,
EDGE_INSET,
MIN_COLS,
MIN_ROWS,
boardForStage,
} from "./stage";

describe("boardForStage", () => {
it("returns null when the stage has no box yet", () => {
expect(boardForStage(0, 0)).toBeNull();
expect(boardForStage(2, 400)).toBeNull();
});

it("floors to whole 20px cells and never scales the cell size", () => {
const wide = 34 * CELL_CSS + 2 * EDGE_INSET + 19;
const tall = 18 * CELL_CSS + 2 * EDGE_INSET + 19;
expect(boardForStage(wide, tall)).toEqual({ cols: 34, rows: 18 });
});

it("grows the board when the stage is larger, still at 20px cells", () => {
const wide = 52 * CELL_CSS + 2 * EDGE_INSET;
const tall = 30 * CELL_CSS + 2 * EDGE_INSET;
expect(boardForStage(wide, tall)).toEqual({ cols: 52, rows: 30 });
});

it("does not drop below the playable minimum", () => {
expect(boardForStage(MIN_COLS * CELL_CSS, MIN_ROWS * CELL_CSS)).toEqual({
cols: MIN_COLS,
rows: MIN_ROWS,
});
});
});
39 changes: 39 additions & 0 deletions frontend/src/ui/arcade/snake/stage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* Snake stage geometry. The playfield fills the cabinet with more cells at a
* FIXED 20 CSS-px size. Cells are never scaled up to fit; that reads as zoom.
*/

/** Original game cell size in CSS pixels. Never scaled up to fill the stage. */
export const CELL_CSS = 20;
/** Smallest playable board if the stage is tiny. */
export const MIN_COLS = 20;
export const MIN_ROWS = 12;
/**
* Minimum letterbox per side. The stage surface clips with a 10px border
* radius; anything closer than ~3px to its edge gets a bite taken out of the
* corner cells, which players read as "the end cell is cut off".
*/
export const EDGE_INSET = 4;

/** Board size in whole cells for the current stage box. */
export interface SnakeBoard {
readonly cols: number;
readonly rows: number;
}

/**
* How many whole FIXED-size cells fit in the stage. Returns null when the
* box has not been measured yet (jsdom / hidden).
*/
export function boardForStage(
clientWidth: number,
clientHeight: number,
): SnakeBoard | null {
const boxW = clientWidth - 2 * EDGE_INSET;
const boxH = clientHeight - 2 * EDGE_INSET;
if (boxW < 1 || boxH < 1) return null;
return {
cols: Math.max(MIN_COLS, Math.floor(boxW / CELL_CSS)),
rows: Math.max(MIN_ROWS, Math.floor(boxH / CELL_CSS)),
};
}
Loading