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
2 changes: 1 addition & 1 deletion backend/assets/campus.geometry.json

Large diffs are not rendered by default.

14 changes: 8 additions & 6 deletions docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,12 +161,14 @@ failure teardown, from PR #148 review) is still tracked as an open item — see

## 9. Arcade cabinets

**What it is:** Single-player mini-games playable at map-placed arcade cabinets: **Snake** and **Flappy**
(a third game, **2048, has been retired** and is no longer offered).

**How to use it:** Walk up to a cabinet and interact; a lazy-loaded overlay (`ArcadeOverlay`) opens over the
game world (which sleeps underneath, same pattern as meetings). Escape closes it instantly. Score is
submitted when the run ends, and a high-score list is shown.
**What it is:** Single-player mini-games playable at map-placed arcade cabinets: **Snake**, **Flappy**,
**Stellar Forge** (merge-drop), **Breakout**, and **Minesweeper** (2048 was retired and is no longer offered).

**How to use it:** Walk south from spawn down the stone path into the Game Arcade. Walk up to a cabinet
and press **E**. A lazy-loaded overlay opens (the world sleeps underneath). Escape pauses (Resume /
Restart / Quit); F toggles fullscreen; M mutes arcade sound. Score is submitted when the run ends, and
a high-score list is shown per cabinet. Breakout: A/D or mouse to aim, click or Space to serve.
Minesweeper: click or Space to open, right-click or X to flag, Shift-click to chord.

**Status & caveats:** Live. Game rules are pure, deterministic modules in `frontend/src/game/arcade/`
(`snake.ts`, `flappy.ts`, plus a shared seeded PRNG) — no `Math.random`, everything replayable from a seed.
Expand Down
1 change: 1 addition & 0 deletions frontend/ATTRIBUTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ 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. |
| `furniture/arcade_breakout.png`, `furniture/arcade_minesweeper.png` | Project-original | Generated in-repo (`scripts/gen_arcade_sprites.py`) | Project asset — same 32×32 cabinet body, marquee/screen tinted coral (`#e2564d`) for Breakout and blue (`#5b8def`) for Minesweeper. 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
12 changes: 11 additions & 1 deletion frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -608,7 +608,7 @@ the feedback card.
**Architecture — pure rules, thin renderers, audio-agnostic:**

- **Rules** live in pure modules under `src/game/arcade/` — `snake.ts`,
`flappy.ts`, `mergedrop.ts`, plus a seeded PRNG (`prng.ts`). Plain values in,
`flappy.ts`, `mergedrop.ts`, `breakout.ts`, `minesweeper.ts`, plus a seeded PRNG (`prng.ts`). Plain values in,
plain values out; no Phaser/net/DOM imports. Randomness flows through the
serializable `rngSeed` in each state, so *a given seed + input script always
reproduces a run* (asserted by determinism tests). Each module lands with its
Expand Down Expand Up @@ -661,6 +661,16 @@ the map — never hand-edit `campus.json`. Add a cabinet sprite via

#### Stellar Forge — the merge-drop cabinet (Arcade 2.0)

**Breakout** (`breakout`, west wall of the Arcade Hall at tile 68,96). Aim the
paddle with the mouse or A/D (or arrows); click or Space to serve. Clear the
brick field to advance a level; three missed balls end the run. Combo multiplies
brick points.

**Minesweeper** (`minesweeper`, east of Stellar Forge at tile 85,96). 10×10
board, 12 mines. Click or Space opens a cell (the first click is always safe);
right-click or X flags; Shift-click / middle-click chords a numbered cell.
Score is 10 per safe reveal plus a win bonus and leftover time.

The flagship cabinet (`merge-drop`, east of the Arcade Hall doorway at tile
82,96). You release celestial bodies into a gravity well; two touching bodies of
the same tier fuse into the next rung of a ten-step evolution ladder (Pebble →
Expand Down
Binary file added frontend/public/assets/furniture/arcade_breakout.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion frontend/public/assets/maps/campus.json

Large diffs are not rendered by default.

32 changes: 17 additions & 15 deletions frontend/scripts/campus_decor/arcade_hall.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,26 @@
"""Game Arcade hall — LimeZu art pass (games room).

The room is small (19x13 interior) and holds four things, nothing else: the
three arcade cabinets on the north wall (Snake, Flappy, Stellar Forge), the two
board tables on a parquet mat in the south-west, and a minimal snack point
against the east wall. Two wall sprites dress the cabinet line. The middle is
deliberately empty spectator floor.
arcade cabinets on the north wall (Breakout, Snake, Minesweeper, Flappy,
Stellar Forge), the two board tables on a parquet mat in the south-west, and a
minimal snack point against the east wall. Two wall sprites dress the cabinet
line. The middle is deliberately empty spectator floor.

Geometry it has to live with (all frozen):
interior x=68..86, y=95..107 door gap x=78-81 on the north wall (y=94)
cabinets f_arcade_snake @ (71,96), f_arcade_flappy @ (76,96),
f_arcade_merge-drop @ (82,96)
each pairs with a 2x4 interactable (snake 71-72 / flappy 76-77 /
merge-drop 82-83, y=96-99)
cabinets f_arcade_breakout @ (68,96), f_arcade_snake @ (71,96),
f_arcade_flappy @ (76,96), f_arcade_merge-drop @ (82,96),
f_arcade_minesweeper @ (85,96)
each pairs with a 2x4 interactable (breakout 68-69 / snake 71-72 /
flappy 76-77 / merge-drop 82-83 / minesweeper 85-86, y=96-99)
board ttt table (72,102) seats (70,102)(74,102);
c4 table (72,105) seats (70,105)(74,105)
stage arcade_zone covers the full interior (not a placement constraint)

Walkability rules the placement below obeys (player body is 18px; tiles are 16px,
so every lane must be >=2 tiles):
· cols 78-81 stay free of solid bodies — door spine from the threshold south
· rows 98-99 under both cabinet interactables stay clear (play stance)
· rows 98-99 under the cabinet interactables stay clear (play stance)
· the board-table block (x=70-74, y=102-105) carries no props at all; it is
approached from the east (x>=76) and from the north (y=100-101 cross-aisle)
· the room centre (x=76-83, y=100-106) is open floor — spectator space
Expand Down Expand Up @@ -52,7 +53,8 @@ def _paint_floor(ctx) -> None:
# tile of the snack corner. The centre stays plain cream.
ctx.fill_pattern(AH_X0, AH_Y0, AH_X1, AH_Y1, ctx.FLOOR_CREAM)
ctx.fill_pattern(70, 101, 74, 106, ctx.FLOOR_PARQUET) # board-table mat
ctx.fill_pattern(84, 95, 86, 99, ctx.FLOOR_SLATE) # snack corner (E wall)
ctx.fill_pattern(84, 95, 86, 99, ctx.FLOOR_SLATE) # minesweeper pad (E wall)
ctx.fill_pattern(84, 101, 86, 104, ctx.FLOOR_SLATE) # relocated snack corner
# Door gap: shell paves stone; cream threshold so the entrance reads as room.
ctx.fill_pattern(78, 94, 81, 94, ctx.FLOOR_CREAM)

Expand All @@ -61,10 +63,10 @@ def _furnish(ctx) -> None:
# Cols 78-81 never solid. Rows 98-99 under the cabinets stay free of solids.

# ── Cabinet wall dressing (non-solid, flush to the north wall) ────────
ctx.furn("f_lz_poster_grid", 69, 95, False) # poster left of the snake cab
# Chalk scoreboard, hung in the gap between the two cabinet sprites
# (snake ends at x=72.5, flappy starts at x=75.5).
# Poster in the gap between Breakout (68) and Snake (71).
ctx.furn("f_lz_poster_grid", 70, 95, False)
# Chalk scoreboard in the gap between Snake (71) and Flappy (76).
ctx.furn("f_lz_chalkboard", 74, 95, False)

# ── Snack point (east wall) ───────────────────────────────────────────
ctx.furn("f_lz_vending_shelf", 85, 96, True)
# ── Snack point (east wall, south of the Minesweeper cabinet) ─────────
ctx.furn("f_lz_vending_shelf", 85, 102, True)
4 changes: 3 additions & 1 deletion frontend/scripts/gen_arcade_sprites.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
marquee + screen glow is tinted per cabinet). Cohesive with the existing 16/32px
Pipoya-family furniture PNGs; NOT derived from any third-party pack.

Writes: public/assets/furniture/arcade_{snake,flappy,merge-drop}.png
Writes: public/assets/furniture/arcade_{snake,flappy,merge-drop,breakout,minesweeper}.png
Run: python3 scripts/gen_arcade_sprites.py
"""
import os
Expand All @@ -31,6 +31,8 @@
"snake": (127, 209, 185, 255), # teal #7fd1b9
"flappy": (242, 193, 78, 255), # amber #f2c14e
"merge-drop": (176, 130, 255, 255), # violet #b082ff — Stellar Forge
"breakout": (226, 86, 77, 255), # coral #e2564d
"minesweeper": (91, 141, 239, 255), # blue #5b8def
}


Expand Down
31 changes: 28 additions & 3 deletions frontend/scripts/gen_campus.py
Original file line number Diff line number Diff line change
Expand Up @@ -535,7 +535,19 @@ def plant_tree(block, tx, ty):
# tiles below it: the 32px solid cabinet body clears by row 98, so rows 98-99
# give a collision-free approach strip that is still inside the findNear rect.
# `game` selects the module. Snake/Flappy sit west of the north doorway
# (x=79-80), Stellar Forge east of it. (2048 was retired — PRD 25.36.)
# (x=79-80), Stellar Forge east of it. Breakout is against the west wall;
# Minesweeper sits east of Stellar Forge (the north-wall run east of the door).
# (2048 was retired — PRD 25.36.)
{
"id": 40013, "name": "arcade_breakout",
"x": 68 * TS, "y": 96 * TS, "width": 2 * TS, "height": 4 * TS,
"rotation": 0, "type": "", "visible": True,
"properties": [
{"name": "interactType", "type": "string", "value": "arcade"},
{"name": "label", "type": "string", "value": "Breakout"},
{"name": "game", "type": "string", "value": "breakout"},
],
},
{
"id": 40010, "name": "arcade_snake",
"x": 71 * TS, "y": 96 * TS, "width": 2 * TS, "height": 4 * TS,
Expand All @@ -546,6 +558,16 @@ def plant_tree(block, tx, ty):
{"name": "game", "type": "string", "value": "snake"},
],
},
{
"id": 40014, "name": "arcade_minesweeper",
"x": 85 * TS, "y": 96 * TS, "width": 2 * TS, "height": 4 * TS,
"rotation": 0, "type": "", "visible": True,
"properties": [
{"name": "interactType", "type": "string", "value": "arcade"},
{"name": "label", "type": "string", "value": "Minesweeper"},
{"name": "game", "type": "string", "value": "minesweeper"},
],
},
{
"id": 40011, "name": "arcade_flappy",
"x": 76 * TS, "y": 96 * TS, "width": 2 * TS, "height": 4 * TS,
Expand Down Expand Up @@ -678,11 +700,14 @@ def furn(key, tx, ty, solid):
# Arcade Room (south) — solid cabinets lining the north wall; each pairs with an
# arcade interactable zone at the same tile (see interactables_objs). Plus a
# little themed dressing so the hall doesn't read empty.
furn("f_arcade_breakout", 68, 96, True)
furn("f_arcade_snake", 71, 96, True)
furn("f_arcade_flappy", 76, 96, True)
# Stellar Forge — east of the doorway (cols 78-81 free), west of the snack
# corner dressing in campus_decor/arcade_hall.py. Same wall run as its neighbours.
# Stellar Forge — east of the doorway (cols 78-81 free). Minesweeper continues
# the same north-wall run east of it; the snack vending moves south along the
# east wall in campus_decor/arcade_hall.py so the two 32px bodies do not overlap.
furn("f_arcade_merge-drop", 82, 96, True)
furn("f_arcade_minesweeper", 85, 96, True)
# Arcade dressing: re-authored in scripts/campus_decor/ (LimeZu art pass).


Expand Down
187 changes: 187 additions & 0 deletions frontend/src/game/arcade/breakout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { describe, expect, it } from "vitest";
import {
BASE_BALL_SPEED,
BREAKOUT_COLS,
BREAKOUT_HEIGHT,
BREAKOUT_LIVES,
BREAKOUT_STEP,
BREAKOUT_WIDTH,
breakoutTick,
initBreakout,
type BreakoutInput,
type BreakoutState,
} from "./breakout";

const HOLD: BreakoutInput = { aim: null, move: 0, launch: false };
const LAUNCH: BreakoutInput = { aim: null, move: 0, launch: true };

function play(state: BreakoutState, input: BreakoutInput = HOLD): BreakoutState {
return breakoutTick(state, input).state;
}

function launch(seed = 1): BreakoutState {
return play(initBreakout(seed), LAUNCH);
}

describe("initBreakout", () => {
it("starts ready with a full brick field, three lives, and a parked ball", () => {
const s = initBreakout(1);
expect(s.phase).toBe("ready");
expect(s.lives).toBe(BREAKOUT_LIVES);
expect(s.score).toBe(0);
expect(s.level).toBe(1);
expect(s.bricks.length).toBeGreaterThan(BREAKOUT_COLS * 3);
expect(s.bricks.every((b) => b.hp > 0)).toBe(true);
expect(s.ballVx).toBe(0);
expect(s.ballVy).toBe(0);
expect(s.paddleX).toBe(BREAKOUT_WIDTH / 2);
expect(s.ballX).toBe(s.paddleX);
expect(s.height).toBe(BREAKOUT_HEIGHT);
expect(s.paddleY).toBe(BREAKOUT_HEIGHT - 28);
});

it("accepts a taller court without changing the brick field width", () => {
const s = initBreakout(1, 720);
expect(s.width).toBe(BREAKOUT_WIDTH);
expect(s.height).toBe(720);
expect(s.paddleY).toBe(720 - 28);
const desktop = initBreakout(1);
expect(s.bricks.length).toBe(desktop.bricks.length);
expect(s.bricks[0]?.x).toBe(desktop.bricks[0]?.x);
expect(s.bricks[0]?.w).toBe(desktop.bricks[0]?.w);
});
});

describe("breakoutTick — ready / serve", () => {
it("keeps the ball glued to a steered paddle until launch", () => {
const left = play(initBreakout(1), { aim: null, move: -1, launch: false });
expect(left.phase).toBe("ready");
expect(left.paddleX).toBeLessThan(BREAKOUT_WIDTH / 2);
expect(left.ballX).toBe(left.paddleX);
expect(left.ballY).toBeLessThan(left.paddleY);
});

it("serves on launch and starts the run", () => {
const s = launch(7);
expect(s.phase).toBe("play");
expect(s.ballVy).toBeLessThan(0);
expect(Math.abs(s.ballVx) + Math.abs(s.ballVy)).toBeGreaterThan(0);
});

it("is a no-op once the run is over", () => {
const over: BreakoutState = { ...initBreakout(1), phase: "over", lives: 0 };
expect(breakoutTick(over, LAUNCH).state).toBe(over);
expect(breakoutTick(over, LAUNCH).events).toEqual([]);
});
});

describe("breakoutTick — paddle and walls", () => {
it("clamps pointer aim to the inner walls", () => {
const s = play(initBreakout(1), { aim: -400, move: 0, launch: false });
expect(s.paddleX).toBeGreaterThan(s.paddleW / 2);
const right = play(initBreakout(1), { aim: 4000, move: 0, launch: false });
expect(right.paddleX).toBeLessThan(BREAKOUT_WIDTH - s.paddleW / 2);
});

it("bounces off the side walls instead of leaving the court", () => {
let s = launch(3);
s = {
...s,
ballX: 12,
ballY: 220,
ballVx: -400,
ballVy: 0,
};
const next = play(s);
expect(next.ballVx).toBeGreaterThan(0);
expect(next.ballX).toBeGreaterThanOrEqual(s.ballR);
});
});

describe("breakoutTick — bricks, lives, levels", () => {
it("breaks a brick, awards its points, and emits brick", () => {
const base = launch(1);
const brick = base.bricks[0];
if (!brick) throw new Error("expected a brick");
const aimed: BreakoutState = {
...base,
ballX: brick.x + brick.w / 2,
ballY: brick.y + brick.h + base.ballR - 0.5,
ballVx: 0,
ballVy: -BASE_BALL_SPEED,
};
const step = breakoutTick(aimed, HOLD);
expect(step.events).toContain("brick");
expect(step.state.score).toBeGreaterThan(0);
expect(step.state.bricks.length).toBeLessThan(aimed.bricks.length);
});

it("drops a life and returns to ready when the ball falls off the bottom", () => {
const s: BreakoutState = {
...launch(1),
ballX: BREAKOUT_WIDTH / 2,
ballY: BREAKOUT_HEIGHT + 20,
ballVx: 0,
ballVy: 200,
};
const step = breakoutTick(s, HOLD);
expect(step.events).toContain("life");
expect(step.state.lives).toBe(BREAKOUT_LIVES - 1);
expect(step.state.phase).toBe("ready");
expect(step.state.combo).toBe(0);
});

it("ends the run on the last dropped life", () => {
const s: BreakoutState = {
...launch(1),
lives: 1,
ballX: BREAKOUT_WIDTH / 2,
ballY: BREAKOUT_HEIGHT + 20,
ballVx: 0,
ballVy: 200,
};
const step = breakoutTick(s, HOLD);
expect(step.events).toContain("over");
expect(step.state.phase).toBe("over");
expect(step.state.lives).toBe(0);
});

it("refills a harder field when the last brick is cleared", () => {
const base = launch(1);
const last = base.bricks[0];
if (!last) throw new Error("expected a brick");
const s: BreakoutState = {
...base,
bricks: [{ ...last, hp: 1 }],
ballX: last.x + last.w / 2,
ballY: last.y + last.h + base.ballR - 0.5,
ballVx: 0,
ballVy: -BASE_BALL_SPEED,
};
const step = breakoutTick(s, HOLD);
expect(step.events).toContain("level");
expect(step.state.level).toBe(2);
expect(step.state.bricks.length).toBeGreaterThan(1);
expect(step.state.phase).toBe("play");
});
});

describe("determinism", () => {
function run(seed: number): BreakoutState {
let s = initBreakout(seed);
for (let i = 0; i < 4000 && s.phase !== "over"; i += 1) {
const launchNow = s.phase === "ready" && i % 30 === 0;
const move = (i % 80 < 40 ? -1 : 1) as -1 | 1;
s = breakoutTick(s, { aim: null, move, launch: launchNow }, BREAKOUT_STEP).state;
}
return s;
}

it("same seed + input script ⇒ identical outcome", () => {
expect(run(555)).toEqual(run(555));
});

it("different seeds diverge", () => {
expect(run(555).rngSeed).not.toBe(run(777).rngSeed);
});
});
Loading
Loading