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
10 changes: 5 additions & 5 deletions backend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import rateLimit, { type RateLimitInfo } from "express-rate-limit";
import { AccessToken, TrackSource } from "livekit-server-sdk";
import { z } from "zod";
import {
ARCADE_GAMES,
ARCADE_SCORE_GAMES,
LIMITS,
RATE_LIMITS,
arcadeScoreSchema,
Expand All @@ -12,7 +12,7 @@ import {
credentialsSchema,
liveKitSchema,
reportCreateSchema,
type ArcadeGame,
type ArcadeScoreGame,
type ArcadeLeaderboard,
type ArcadeScoreResult,
type BlockAck,
Expand Down Expand Up @@ -151,8 +151,8 @@ const blockLimiter = rateLimit({
},
});

function isArcadeGame(value: string): value is ArcadeGame {
return (ARCADE_GAMES as readonly string[]).includes(value);
function isArcadeScoreGame(value: string): value is ArcadeScoreGame {
return (ARCADE_SCORE_GAMES as readonly string[]).includes(value);
}

api.post("/signup", authLimiter, async (request, response) => {
Expand Down Expand Up @@ -247,7 +247,7 @@ api.post("/arcade/scores", arcadeScoreLimiter, requireAuth, async (request, resp
api.get("/arcade/scores/:game", arcadeLeaderboardLimiter, requireAuth, async (request, response) => {
const raw = Array.isArray(request.params.game) ? request.params.game[0] : request.params.game;
const game = raw ?? "";
if (!isArcadeGame(game)) {
if (!isArcadeScoreGame(game)) {
response.status(404).json({ error: "unknown-game" });
return;
}
Expand Down
14 changes: 7 additions & 7 deletions backend/test/integration/api.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -506,21 +506,21 @@ describe("arcade high scores", () => {
const zero = await api(base, "/api/v1/arcade/scores", { token, body: { game: "flappy", score: 0 } });
expect(zero.json.best).toBe(0);
expect(zero.json.newBest).toBe(false);
const first = await api(base, "/api/v1/arcade/scores", { token, body: { game: "snake", score: 12 } });
const first = await api(base, "/api/v1/arcade/scores", { token, body: { game: "snake-v2", score: 12 } });
expect(first.status).toBe(200);
expect(first.json.best).toBe(12);
expect(first.json.newBest).toBe(true);
expect(first.json.game).toBe("snake");
expect(first.json.game).toBe("snake-v2");
// A lower score does not lower the stored best.
const lower = await api(base, "/api/v1/arcade/scores", { token, body: { game: "snake", score: 5 } });
const lower = await api(base, "/api/v1/arcade/scores", { token, body: { game: "snake-v2", score: 5 } });
expect(lower.json.best).toBe(12);
expect(lower.json.newBest).toBe(false);
// Matching the standing best is not an improvement either.
const equal = await api(base, "/api/v1/arcade/scores", { token, body: { game: "snake", score: 12 } });
const equal = await api(base, "/api/v1/arcade/scores", { token, body: { game: "snake-v2", score: 12 } });
expect(equal.json.best).toBe(12);
expect(equal.json.newBest).toBe(false);
// A higher score raises it.
const higher = await api(base, "/api/v1/arcade/scores", { token, body: { game: "snake", score: 20 } });
const higher = await api(base, "/api/v1/arcade/scores", { token, body: { game: "snake-v2", score: 20 } });
expect(higher.json.best).toBe(20);
expect(higher.json.newBest).toBe(true);
// The leaderboard lists this user's best with their username.
Expand All @@ -530,7 +530,7 @@ describe("arcade high scores", () => {

it("atomically identifies only one of two identical first submissions as the new best", async () => {
const { token } = await createPlayer("arc-race");
const body = { game: "snake", score: 9 };
const body = { game: "snake-v2", score: 9 };
const [left, right] = await Promise.all([
api(base, "/api/v1/arcade/scores", { token, body }),
api(base, "/api/v1/arcade/scores", { token, body }),
Expand All @@ -557,7 +557,7 @@ describe("arcade high scores", () => {
it("rejects an unknown game and a malformed score", async () => {
const { token } = await createPlayer("arc3");
expect((await api(base, "/api/v1/arcade/scores/pong", { token })).status).toBe(404);
const bad = await api(base, "/api/v1/arcade/scores", { token, body: { game: "snake", score: -1 } });
const bad = await api(base, "/api/v1/arcade/scores", { token, body: { game: "snake-v2", score: -1 } });
expect(bad.status).toBe(400);
expect(bad.json.error).toBe("invalid-score");
});
Expand Down
1 change: 0 additions & 1 deletion frontend/ATTRIBUTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,6 @@ release.
| `furniture/table_round.png` | Project-original | Redrawn in-repo (PRD 12) | Project asset — replaces the prior flat placeholder, matched to the furniture palette. |
| `furniture/arcade_snake.png`, `furniture/arcade_flappy.png` | Project-original | Generated in-repo (PRD 11, `scripts/gen_arcade_sprites.py`) | Project asset — 32×32 upright arcade cabinets in the cool-office palette, marquee/screen tinted per game (teal/amber). Not derived from any third-party pack. |
| `furniture/arcade_merge-drop.png` | Project-original | Generated in-repo (Arcade 2.0, `scripts/gen_arcade_sprites.py`) | Project asset — the Stellar Forge cabinet: same 32×32 body as its neighbours, marquee/screen tinted violet (`#b082ff`). Not derived from any third-party pack. |
| `arcade/snake_tiles.png` | Project-original | Generated in-repo (issue #163, `scripts/gen_snake_sprites.py`) | Project asset — 16×16 sprite tiles for the Snake mini-game (head/body/bend/tail per direction, apple, wall block), replacing the renderer's flat rectangles. Same outline ink and cool palette as the arcade cabinets. Consumed only by the lazy-loaded arcade overlay canvas, not by Phaser/BootScene. Not derived from any third-party pack. |
| `tilesets/room_builder.png`, `furniture/lz_*.png` | [Modern Interiors — RPG Tileset](https://limezu.itch.io/moderninteriors), **free** tier (`Modern_Interiors_Free_v2.2`, `Interiors_free/16x16`) | **LimeZu** | ⚠️ **NON-COMMERCIAL ONLY.** The free tier's `Modern tiles_Free/LICENSE.txt` reads verbatim: *"YOU CAN USE THE ASSET IN NON COMMERCIAL PROJECTS · YOU CAN EDIT THE SPRITES AND USE THEM IN NON COMMERCIAL PROJECTS / YOU CAN'T USE THE ASSET IN COMMERCIAL PROJECTS · YOU CAN'T EDIT THE SPRITES AND USE THEM IN COMMERCIAL PROJECTS · YOU CAN'T EDIT AND RESELL THE SPRITES."* That is fine today because hyprverse is Raja's personal, non-commercial project. **If it is ever monetised, sponsored or shipped as a product, buy the paid pack (~$1.20, carries the commercial licence) before shipping — or strip these assets.** Shipped: `room_builder.png` verbatim (interior floor tiles: brick / cross-tile / mint / screed / herringbone parquet), and 102 prop cutouts from `Interiors_free_16x16.png` cropped by `scripts/gen_limezu_sprites.py` (source tile coordinates named in that script). The raw sheets are **not** committed. Used campus-wide by the district art passes in `scripts/campus_decor/` (see `docs/ux-art-audit-2026-08-01.md` and `docs/art/limezu-catalog.md`). |
| `landing/campus-hero.png` | Composed in-repo (`scripts/gen_landing_backdrop.py`) from `maps/campus.json` + the `exterior.png` / `floors_walls.png` tilesets and `furniture/*.png` (all attributed above) | (respective pack authors) | Landing diorama backdrop (PRD 19) — a pure composition of already-attributed pack assets: a fixed crop of the real campus map re-rendered to one palette-quantised PNG. No new art authored; inherits the licenses of its source tiles/sprites. |

Expand Down
50 changes: 16 additions & 34 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -583,7 +583,8 @@ its focus can't swallow the game's keys via the scene's `isTyping` guard).
slider bound to a dedicated **`arcade` mixer channel** (`arcadeVolume` /
`muteArcade` in `ui/settings.ts`). Kept in the (lazy-loaded) overlay rather than
the core Settings panel so the entry bundle stays lean under the size budget.
Arcade blips (`open-arcade`, `arcade-eat`, `arcade-point`, `arcade-near`,
Arcade blips (`open-arcade`, `arcade-eat`, `arcade-bonus`, `arcade-milestone`,
`arcade-highscore`, `arcade-snake-over`, `arcade-point`, `arcade-near`,
`arcade-flap`, `arcade-over`, `arcade-best`, and the merge-drop
`arcade-drop`/`arcade-merge`/`arcade-nova`) route on that channel in the pure
`soundMixer` `EVENT_SOUNDS` table, so a player can quiet a noisy game
Expand All @@ -594,18 +595,15 @@ per-game volume would mean threading the active game through the global bridge f
marginal value — arcade-wide is the clean seam and still honours the pure-mixer
rule (games stay audio-agnostic; the mixer decides the blip).

**Per-run options + juice (issue #163).** Snake's cabinet carries a **speed**
picker (chill / normal / fast) and a **level** picker; the header carries a
**screen-shake** toggle. All three persist through the shared settings store
(`snakeSpeed`, `snakeLevel`, `arcadeShake` in `ui/settings.ts` — imported there
**type-only** so no arcade code reaches the entry chunk). Picking an option
**bumps the run seed**, which remounts the game component; a renderer therefore
reads its options exactly once per run and a mid-run change can never reshape the
board under the player. Death shows a card with a one-key restart (Space / Enter /
R). Screen shake is suppressed by the shake toggle **and** by the global
reduced-motion preference, like every other decorative motion in the app. Both
canvas renderers subscribe through `useReducedMotion()`,
so an OS or in-app preference change applies to the active run without a restart.
**Snake's faithful port.** Snake keeps the owner-approved white board: a local
difficulty menu (easy / normal / hard), bonus food, crisp fixed-size cells,
instant steering, and interpolation glide between reducer ticks. Its difficulty
is stored inside the lazy game surface, not in the shared HUD settings. The
header's **screen-shake** toggle remains shared by the other cabinets; the white
Snake renderer accepts that live prop but intentionally has no shake layer.
Death reports immediately so the overlay can submit the score and use the
server's authoritative `newBest` result while its existing terminal hold shows
the feedback card.

**Architecture — pure rules, thin renderers, audio-agnostic:**

Expand All @@ -615,32 +613,16 @@ so an OS or in-app preference change applies to the active run without a restart
serializable `rngSeed` in each state, so *a given seed + input script always
reproduces a run* (asserted by determinism tests). Each module lands with its
vitest file in the same commit.
- **Level layouts and speed tiers are DATA in the rules module**, not renderer
constants: `SNAKE_LEVELS` parses ASCII maps (`#` wall, `>` start) into
`{ width, height, walls, start }` at module load and `SNAKE_SPEEDS` holds the
tick cadence per tier. The reducer is level-agnostic — a wall cell is as lethal
as the board edge, food never spawns on one, and a win means filling every
*non-wall* cell. **Adding a level is adding rows to `SNAKE_LEVELS`**; the level
tests then check its geometry, its start, and that every one of its wall cells
kills, automatically.
- **Juice is a pure reducer too** (`juice.ts`): particles, score pop-ups and
screen-shake energy, with every random draw threaded through its own `rngSeed`
— never `Math.random` — so the feedback is as reproducible as the game. It is
seeded from the run seed but keeps a *separate* stream, so adding or removing a
particle burst can never perturb the game's own randomness. `feedback.ts` holds
the read-only near-miss predicates; nothing there is consulted by a reducer.
- **Snake feedback is split into draw-only helpers** under
`src/ui/arcade/snake/`: white grid, food/bonus animation, death FX,
interpolation, and rounded body drawing. The reducer remains the only source
of gameplay decisions.
- **Renderers** are thin React components (`src/ui/arcade/`): one per game, they
own a canvas/DOM surface, run the module's tick/reduce on a loop, draw the
returned state, and report score/game-over upward. No game *rules* in a
renderer or the scene. The overlay (`ArcadeOverlay.tsx`) and its game modules
are **lazy-loaded** — a separate chunk, so no cabinet's code ever bloats the
entry bundle (only the tiny `ARCADE_GAMES` registry entry is shared). Snake draws from a 16-tile sprite sheet
(`public/assets/arcade/snake_tiles.png`, generated by
`scripts/gen_snake_sprites.py`); which tile a segment uses is geometry, so it
lives in the pure `snakeSprites.ts` and the renderer only blits the index. The
sheet is loaded by the overlay canvas with a plain `Image`, **not** through
BootScene — it is not a Phaser world texture, and registering it there would
make every player download it whether or not they ever open a cabinet.
entry bundle (only the tiny `ARCADE_GAMES` registry entry is shared).
Flappy's drawing code is split
its art into small draw-only modules beside it — Flappy's live in
`src/ui/arcade/flappy/` (`scenery`, `pipes`, `bird`, `hud`, `fx`, `render`),
Expand Down
Binary file removed frontend/public/assets/arcade/snake_tiles.png
Binary file not shown.
Loading
Loading