diff --git a/apps/rail-drop/.claude/launch.json b/apps/rail-drop/.claude/launch.json
new file mode 100644
index 000000000..d877c0544
--- /dev/null
+++ b/apps/rail-drop/.claude/launch.json
@@ -0,0 +1,11 @@
+{
+ "version": "0.0.1",
+ "configurations": [
+ {
+ "name": "rail-drop",
+ "runtimeExecutable": "npx",
+ "runtimeArgs": ["vite", "dev", "--host", "--port", "3002"],
+ "port": 3002
+ }
+ ]
+}
diff --git a/apps/rail-drop/lingui.config.ts b/apps/rail-drop/lingui.config.ts
new file mode 100644
index 000000000..9f18a0be2
--- /dev/null
+++ b/apps/rail-drop/lingui.config.ts
@@ -0,0 +1,3 @@
+import config from 'config-lingui';
+
+export default config;
diff --git a/apps/rail-drop/package.json b/apps/rail-drop/package.json
new file mode 100644
index 000000000..632dc792f
--- /dev/null
+++ b/apps/rail-drop/package.json
@@ -0,0 +1,48 @@
+{
+ "name": "rail-drop",
+ "version": "0.0.0",
+ "private": true,
+ "license": "MIT",
+ "scripts": {
+ "format": "prettier --write --ignore-path=../../.prettierignore .",
+ "lint": "eslint \"src\"",
+ "dev": "vite dev --host --port 3002",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "type": "module",
+ "devDependencies": {
+ "eslint": "9.21.0",
+ "eslint-config-custom": "workspace:*",
+ "config-ts": "workspace:*",
+ "config-vite": "workspace:*",
+ "config-svelte": "workspace:*",
+ "config-lingui": "workspace:*",
+ "@sveltejs/vite-plugin-svelte": "5.0.3"
+ },
+ "dependencies": {
+ "lodash": "4.17.16",
+ "@types/lodash": "4.17.16",
+ "svelte": "5.20.5",
+ "vite": "6.2.0",
+ "@sveltejs/kit": "2.17.3",
+ "@lingui/core": "5.2.0",
+ "envs": "workspace:*",
+ "rgs-requests": "workspace:*",
+ "pixi-svelte": "workspace:*",
+ "state-shared": "workspace:*",
+ "constants-shared": "workspace:*",
+ "components-shared": "workspace:*",
+ "components-layout": "workspace:*",
+ "components-ui-html": "workspace:*",
+ "components-pixi": "workspace:*",
+ "components-ui-pixi": "workspace:*",
+ "utils-event-emitter": "workspace:*",
+ "utils-shared": "workspace:*",
+ "utils-xstate": "workspace:*",
+ "utils-book": "workspace:*",
+ "utils-bet": "workspace:*",
+ "utils-layout": "workspace:*",
+ "utils-sound": "workspace:*"
+ }
+}
diff --git a/apps/rail-drop/src/app.css b/apps/rail-drop/src/app.css
new file mode 100644
index 000000000..38ec993d2
--- /dev/null
+++ b/apps/rail-drop/src/app.css
@@ -0,0 +1,2 @@
+*, *::before, *::after { box-sizing: border-box; }
+body { margin: 0; padding: 0; background: #0a0a14; }
diff --git a/apps/rail-drop/src/app.html b/apps/rail-drop/src/app.html
new file mode 100644
index 000000000..83537c1aa
--- /dev/null
+++ b/apps/rail-drop/src/app.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+ %sveltekit.head%
+
+
+ %sveltekit.body%
+
+
diff --git a/apps/rail-drop/src/components/EnableGameActor.svelte b/apps/rail-drop/src/components/EnableGameActor.svelte
new file mode 100644
index 000000000..85a3dc6a3
--- /dev/null
+++ b/apps/rail-drop/src/components/EnableGameActor.svelte
@@ -0,0 +1,26 @@
+
diff --git a/apps/rail-drop/src/components/Game.svelte b/apps/rail-drop/src/components/Game.svelte
new file mode 100644
index 000000000..78db6423c
--- /dev/null
+++ b/apps/rail-drop/src/components/Game.svelte
@@ -0,0 +1,379 @@
+
+
+
+
+
+
diff --git a/apps/rail-drop/src/game/actor.ts b/apps/rail-drop/src/game/actor.ts
new file mode 100644
index 000000000..c20008141
--- /dev/null
+++ b/apps/rail-drop/src/game/actor.ts
@@ -0,0 +1,22 @@
+import { stateBet } from 'state-shared';
+import { createPrimaryMachines, createIntermediateMachines, createGameActor } from 'utils-xstate';
+
+import type { Bet } from './typesBookEvent';
+import { stateXstateDerived } from './stateXstate';
+import { playBet, convertToResumableBet } from './utils';
+
+const primaryMachines = createPrimaryMachines
({
+ onResumeGameActive: (betToResume) => convertToResumableBet(betToResume),
+ onResumeGameInactive: () => {},
+ onNewGameStart: async () => {
+ if ((stateBet.isTurbo && stateXstateDerived.isAutoBetting()) || stateBet.isSpaceHold) return;
+ stateBet.winBookEventAmount = 0;
+ },
+ onNewGameError: () => {},
+ onPlayGame: async (bet) => await playBet(bet),
+ checkIsBonusGame: () => false,
+});
+
+const intermediateMachines = createIntermediateMachines(primaryMachines);
+
+export const gameActor = createGameActor(intermediateMachines);
diff --git a/apps/rail-drop/src/game/bookEventHandlerMap.ts b/apps/rail-drop/src/game/bookEventHandlerMap.ts
new file mode 100644
index 000000000..ace112460
--- /dev/null
+++ b/apps/rail-drop/src/game/bookEventHandlerMap.ts
@@ -0,0 +1,15 @@
+import type { BookEventHandlerMap } from 'utils-book';
+
+import { eventEmitter } from './eventEmitter';
+import type { BookEvent, BookEventOfType, BookEventContext } from './typesBookEvent';
+
+export const bookEventHandlerMap: BookEventHandlerMap = {
+ drop: async (bookEvent: BookEventOfType<'drop'>) => {
+ await eventEmitter.broadcastAsync({ type: 'reset' });
+ await eventEmitter.broadcastAsync({ type: 'drop', data: bookEvent });
+ },
+ finalWin: async (bookEvent: BookEventOfType<'finalWin'>) => {
+ await eventEmitter.broadcastAsync({ type: 'finalWin', data: bookEvent });
+ },
+ createBonusSnapshot: async () => {},
+};
diff --git a/apps/rail-drop/src/game/config.ts b/apps/rail-drop/src/game/config.ts
new file mode 100644
index 000000000..6258e56a5
--- /dev/null
+++ b/apps/rail-drop/src/game/config.ts
@@ -0,0 +1,21 @@
+export default {
+ providerName: 'sample_provider',
+ gameName: 'Rail Drop',
+ gameID: 'rail_drop',
+ rtp: 0.99,
+ numReels: 0,
+ numRows: [] as number[],
+ betModes: {
+ base: {
+ cost: 1.0,
+ feature: true,
+ buyBonus: false,
+ rtp: 0.99,
+ max_win: 1000,
+ },
+ },
+ symbols: {} as Record,
+ paddingReels: {
+ basegame: '',
+ } as const,
+};
diff --git a/apps/rail-drop/src/game/constants.ts b/apps/rail-drop/src/game/constants.ts
new file mode 100644
index 000000000..4b131d651
--- /dev/null
+++ b/apps/rail-drop/src/game/constants.ts
@@ -0,0 +1,60 @@
+import type { RiskLevel } from './types';
+
+export const RAIL_Y = 90;
+export const RAIL_START_X = 60;
+export const BALL_R = 11;
+export const SLOT_HEIGHT = 48;
+
+// Zone geometry: 3 plinko boards hanging below the rail.
+// Zone 1 (near, 8 rows): conservative multipliers.
+// Zone 2 (mid, 12 rows): higher variance.
+// Zone 3 (far, 16 rows): extreme multipliers.
+export const ZONES = {
+ 1: { rows: 8, centerX: 280, boardTopY: 150, slotWidth: 34, rowHeight: 46, pinRadius: 5 },
+ 2: { rows: 12, centerX: 780, boardTopY: 150, slotWidth: 28, rowHeight: 40, pinRadius: 4 },
+ 3: { rows: 16, centerX: 1310, boardTopY: 150, slotWidth: 22, rowHeight: 35, pinRadius: 3 },
+} as const;
+
+// Real Stake Plinko multipliers (symmetric, ~99% RTP per zone).
+// Indexed by [rows][riskLevel][slotIndex 0..rows].
+export const MULTIPLIERS: Record<8 | 12 | 16, Record> = {
+ 8: {
+ low: [5.6, 2.1, 1.1, 1.0, 0.5, 1.0, 1.1, 2.1, 5.6],
+ medium: [13, 3, 1.3, 0.7, 0.4, 0.7, 1.3, 3, 13 ],
+ high: [29, 4, 1.5, 0.3, 0.2, 0.3, 1.5, 4, 29 ],
+ },
+ 12: {
+ low: [10, 3, 1.6, 1.4, 1.1, 1.0, 0.5, 1.0, 1.1, 1.4, 1.6, 3, 10 ],
+ medium: [33, 11, 4, 2, 1.1, 0.6, 0.3, 0.6, 1.1, 2, 4, 11, 33 ],
+ high: [170, 24, 8.1, 2, 0.7, 0.2, 0.2, 0.2, 0.7, 2, 8.1, 24, 170],
+ },
+ 16: {
+ low: [16, 9, 2, 1.4, 1.4, 1.2, 1.1, 1.0, 0.5, 1.0, 1.1, 1.2, 1.4, 1.4, 2, 9, 16 ],
+ medium: [110, 41, 10, 5, 3, 1.5, 1.0, 0.5, 0.3, 0.5, 1.0, 1.5, 3, 5, 10, 41, 110 ],
+ high: [1000, 130, 26, 9, 4, 2, 0.2, 0.2, 0.2, 0.2, 0.2, 2, 4, 9, 26, 130, 1000],
+ },
+};
+
+// Slot color by multiplier value (symmetric: edges = hot, center = cool).
+export function slotColor(mult: number): string {
+ if (mult >= 100) return '#e53935';
+ if (mult >= 20) return '#e64a19';
+ if (mult >= 10) return '#f57c00';
+ if (mult >= 5) return '#f9a825';
+ if (mult >= 2) return '#558b2f';
+ if (mult >= 1) return '#00695c';
+ if (mult >= 0.5) return '#1565c0';
+ return '#4527a0';
+}
+
+// Ball roll duration per zone (ms).
+export const ROLL_DURATION: Record<1 | 2 | 3, number> = {
+ 1: 700,
+ 2: 1100,
+ 3: 1500,
+};
+
+export const DROP_DURATION = 280;
+export const BOUNCE_DURATION = 130;
+export const BOUNCE_PAUSE = 25;
+export const WIN_DISPLAY_MS = 2800;
diff --git a/apps/rail-drop/src/game/context.ts b/apps/rail-drop/src/game/context.ts
new file mode 100644
index 000000000..37ef88fc4
--- /dev/null
+++ b/apps/rail-drop/src/game/context.ts
@@ -0,0 +1,28 @@
+import { setContextEventEmitter, getContextEventEmitter } from 'utils-event-emitter';
+import { setContextXstate, getContextXstate } from 'utils-xstate';
+import { setContextLayout, getContextLayout } from 'utils-layout';
+import { setContextApp, getContextApp } from 'pixi-svelte';
+
+import { eventEmitter, type EmitterEvent } from './eventEmitter';
+import { stateXstate, stateXstateDerived } from './stateXstate';
+import { stateLayout, stateLayoutDerived } from './stateLayout';
+import { stateApp } from './stateApp';
+import { stateGame, stateGameDerived } from './stateGame.svelte';
+import { i18nDerived } from '../i18n/i18nDerived';
+
+export const setContext = () => {
+ setContextEventEmitter({ eventEmitter });
+ setContextXstate({ stateXstate, stateXstateDerived });
+ setContextLayout({ stateLayout, stateLayoutDerived });
+ setContextApp({ stateApp });
+};
+
+export const getContext = () => ({
+ ...getContextEventEmitter(),
+ ...getContextLayout(),
+ ...getContextXstate(),
+ ...getContextApp(),
+ stateGame,
+ stateGameDerived,
+ i18nDerived,
+});
diff --git a/apps/rail-drop/src/game/eventEmitter.ts b/apps/rail-drop/src/game/eventEmitter.ts
new file mode 100644
index 000000000..3e9e13cf1
--- /dev/null
+++ b/apps/rail-drop/src/game/eventEmitter.ts
@@ -0,0 +1,14 @@
+import { createEventEmitter } from 'utils-event-emitter';
+import type { EmitterEventHotKey } from 'components-shared';
+import type { EmitterEventUi } from 'components-ui-pixi';
+import type { EmitterEventModal } from 'components-ui-html';
+
+import type { EmitterEventGame } from './typesEmitterEvent';
+
+export type EmitterEvent =
+ | EmitterEventHotKey
+ | EmitterEventUi
+ | EmitterEventModal
+ | EmitterEventGame;
+
+export const { eventEmitter } = createEventEmitter();
diff --git a/apps/rail-drop/src/game/sound.ts b/apps/rail-drop/src/game/sound.ts
new file mode 100644
index 000000000..1a8e6a788
--- /dev/null
+++ b/apps/rail-drop/src/game/sound.ts
@@ -0,0 +1,21 @@
+import { createSound } from 'utils-sound';
+
+export type MusicName =
+ | 'bgm_main'
+ | 'bgm_winlevel_big'
+ | 'bgm_winlevel_superwin'
+ | 'bgm_winlevel_mega'
+ | 'bgm_winlevel_epic'
+ | 'bgm_winlevel_max';
+
+export type SoundEffectName =
+ | 'sfx_ball_roll'
+ | 'sfx_ball_drop'
+ | 'sfx_pin_hit'
+ | 'sfx_slot_land'
+ | 'sfx_big_win';
+
+export type SoundName = MusicName | SoundEffectName;
+
+const sound = createSound();
+export { sound };
diff --git a/apps/rail-drop/src/game/stateApp.ts b/apps/rail-drop/src/game/stateApp.ts
new file mode 100644
index 000000000..0e66ed5c2
--- /dev/null
+++ b/apps/rail-drop/src/game/stateApp.ts
@@ -0,0 +1,3 @@
+import { createApp } from 'pixi-svelte';
+
+export const { stateApp } = createApp({ assets: {} });
diff --git a/apps/rail-drop/src/game/stateGame.svelte.ts b/apps/rail-drop/src/game/stateGame.svelte.ts
new file mode 100644
index 000000000..d573bb585
--- /dev/null
+++ b/apps/rail-drop/src/game/stateGame.svelte.ts
@@ -0,0 +1,25 @@
+import { createGetWinLevelDataByWinLevelAlias } from 'utils-shared/winLevel';
+
+import type { GamePhase, RiskLevel, Zone } from './types';
+import { winLevelMap } from './winLevelMap';
+
+export const stateGame = $state({
+ phase: 'idle' as GamePhase,
+ riskLevel: 'medium' as RiskLevel,
+ activeZone: 0 as 0 | Zone,
+ multiplier: 0,
+ finalAmount: 0,
+});
+
+export const resetGame = () => {
+ stateGame.phase = 'idle';
+ stateGame.activeZone = 0;
+ stateGame.multiplier = 0;
+ stateGame.finalAmount = 0;
+};
+
+export const { getWinLevelDataByWinLevelAlias } = createGetWinLevelDataByWinLevelAlias({
+ winLevelMap,
+});
+
+export const stateGameDerived = { getWinLevelDataByWinLevelAlias };
diff --git a/apps/rail-drop/src/game/stateLayout.ts b/apps/rail-drop/src/game/stateLayout.ts
new file mode 100644
index 000000000..e85dc5fda
--- /dev/null
+++ b/apps/rail-drop/src/game/stateLayout.ts
@@ -0,0 +1,14 @@
+import { createLayout } from 'utils-layout';
+
+export const { stateLayout, stateLayoutDerived } = createLayout({
+ backgroundRatio: {
+ normal: 16 / 9,
+ portrait: 9 / 16,
+ },
+ mainSizesMap: {
+ desktop: { width: 1600, height: 900 },
+ tablet: { width: 1200, height: 900 },
+ landscape: { width: 1600, height: 900 },
+ portrait: { width: 900, height: 1600 },
+ },
+});
diff --git a/apps/rail-drop/src/game/stateXstate.ts b/apps/rail-drop/src/game/stateXstate.ts
new file mode 100644
index 000000000..650293518
--- /dev/null
+++ b/apps/rail-drop/src/game/stateXstate.ts
@@ -0,0 +1,3 @@
+import { createXstate } from 'utils-xstate';
+
+export const { stateXstate, stateXstateDerived } = createXstate();
diff --git a/apps/rail-drop/src/game/types.ts b/apps/rail-drop/src/game/types.ts
new file mode 100644
index 000000000..261e64770
--- /dev/null
+++ b/apps/rail-drop/src/game/types.ts
@@ -0,0 +1,9 @@
+import type config from './config';
+
+export type BetMode = keyof typeof config.betModes;
+export type GameType = keyof typeof config.paddingReels;
+export type RiskLevel = 'low' | 'medium' | 'high';
+export type Zone = 1 | 2 | 3;
+export type BallDirection = 'L' | 'R';
+
+export type GamePhase = 'idle' | 'rolling' | 'dropping' | 'bouncing' | 'landed';
diff --git a/apps/rail-drop/src/game/typesBookEvent.ts b/apps/rail-drop/src/game/typesBookEvent.ts
new file mode 100644
index 000000000..859de6155
--- /dev/null
+++ b/apps/rail-drop/src/game/typesBookEvent.ts
@@ -0,0 +1,34 @@
+import type { BetType } from 'rgs-requests';
+import type { RiskLevel, Zone, BallDirection } from './types';
+
+// Server resolves zone, path through pins, and final multiplier.
+type BookEventDrop = {
+ index: number;
+ type: 'drop';
+ zone: Zone;
+ riskLevel: RiskLevel;
+ path: BallDirection[]; // length === zone.rows, one L/R per pin row
+ slotIndex: number; // 0..zone.rows (= number of R's in path)
+ multiplier: number;
+};
+
+type BookEventFinalWin = {
+ index: number;
+ type: 'finalWin';
+ amount: number; // in cents (bet × multiplier)
+};
+
+type BookEventCreateBonusSnapshot = {
+ index: number;
+ type: 'createBonusSnapshot';
+ bookEvents: BookEvent[];
+};
+
+export type BookEvent =
+ | BookEventDrop
+ | BookEventFinalWin
+ | BookEventCreateBonusSnapshot;
+
+export type Bet = BetType;
+export type BookEventOfType = Extract;
+export type BookEventContext = { bookEvents: BookEvent[] };
diff --git a/apps/rail-drop/src/game/typesEmitterEvent.ts b/apps/rail-drop/src/game/typesEmitterEvent.ts
new file mode 100644
index 000000000..076a66b58
--- /dev/null
+++ b/apps/rail-drop/src/game/typesEmitterEvent.ts
@@ -0,0 +1,18 @@
+import type { BookEventOfType } from './typesBookEvent';
+
+export type EmitterEventDrop = {
+ type: 'drop';
+ data: BookEventOfType<'drop'>;
+};
+
+export type EmitterEventFinalWin = {
+ type: 'finalWin';
+ data: BookEventOfType<'finalWin'>;
+};
+
+export type EmitterEventReset = { type: 'reset' };
+
+export type EmitterEventGame =
+ | EmitterEventDrop
+ | EmitterEventFinalWin
+ | EmitterEventReset;
diff --git a/apps/rail-drop/src/game/utils.ts b/apps/rail-drop/src/game/utils.ts
new file mode 100644
index 000000000..62cfde97e
--- /dev/null
+++ b/apps/rail-drop/src/game/utils.ts
@@ -0,0 +1,30 @@
+import { stateBet } from 'state-shared';
+import { createPlayBookUtils } from 'utils-book';
+
+import { eventEmitter } from './eventEmitter';
+import type { Bet, BookEventOfType } from './typesBookEvent';
+import { bookEventHandlerMap } from './bookEventHandlerMap';
+
+export const { playBookEvent, playBookEvents } = createPlayBookUtils({ bookEventHandlerMap });
+
+export const playBet = async (bet: Bet) => {
+ stateBet.winBookEventAmount = 0;
+ await playBookEvents(bet.state);
+ eventEmitter.broadcast({ type: 'stopButtonEnable' });
+};
+
+const SNAPSHOT_EVENT_TYPES: string[] = [];
+
+export const convertToResumableBet = (betToResume: Bet) => {
+ const resumingIndex = Number(betToResume.event);
+ const before = betToResume.state.filter((_, i) => i < resumingIndex);
+ const after = betToResume.state.filter((_, i) => i >= resumingIndex);
+
+ const snapshot: BookEventOfType<'createBonusSnapshot'> = {
+ index: 0,
+ type: 'createBonusSnapshot',
+ bookEvents: before.filter((e) => SNAPSHOT_EVENT_TYPES.includes(e.type)),
+ };
+
+ return { ...betToResume, state: [snapshot, ...after] };
+};
diff --git a/apps/rail-drop/src/game/winLevelMap.ts b/apps/rail-drop/src/game/winLevelMap.ts
new file mode 100644
index 000000000..67b207c0d
--- /dev/null
+++ b/apps/rail-drop/src/game/winLevelMap.ts
@@ -0,0 +1,19 @@
+import { SECOND } from 'constants-shared/time';
+
+export const winLevelMap = {
+ 1: { level: 1, alias: 'zero', type: 'small', text: null, presentDuration: 0, sound: { sfx: undefined, bgm: undefined }, animation: undefined },
+ 2: { level: 2, alias: 'standard', type: 'small', text: null, presentDuration: 0.6 * SECOND, sound: { sfx: undefined, bgm: undefined }, animation: undefined },
+ 3: { level: 3, alias: 'small', type: 'small', text: null, presentDuration: 1 * SECOND, sound: { sfx: undefined, bgm: undefined }, animation: undefined },
+ 4: { level: 4, alias: 'nice', type: 'medium', text: null, presentDuration: 1.5 * SECOND, sound: { sfx: undefined, bgm: undefined }, animation: undefined },
+ 5: { level: 5, alias: 'substantial',type: 'medium', text: null, presentDuration: 2 * SECOND, sound: { sfx: undefined, bgm: undefined }, animation: undefined },
+ 6: { level: 6, alias: 'big', type: 'big', text: 'BIG WIN', presentDuration: 6 * SECOND, sound: { sfx: undefined, bgm: 'bgm_winlevel_big' }, animation: undefined },
+ 7: { level: 7, alias: 'superwin', type: 'big', text: 'SUPER WIN', presentDuration: 18 * SECOND, sound: { sfx: undefined, bgm: 'bgm_winlevel_superwin'}, animation: undefined },
+ 8: { level: 8, alias: 'mega', type: 'big', text: 'MEGA WIN', presentDuration: 20 * SECOND, sound: { sfx: undefined, bgm: 'bgm_winlevel_mega' }, animation: undefined },
+ 9: { level: 9, alias: 'epic', type: 'big', text: 'EPIC WIN!', presentDuration: 26 * SECOND, sound: { sfx: undefined, bgm: 'bgm_winlevel_epic' }, animation: undefined },
+ 10: { level: 10, alias: 'max', type: 'big', text: 'MAX WIN', presentDuration: 32 * SECOND, sound: { sfx: undefined, bgm: 'bgm_winlevel_max' }, animation: undefined },
+} as const;
+
+export type WinLevelMap = typeof winLevelMap;
+export type WinLevel = keyof typeof winLevelMap;
+export type WinLevelData = WinLevelMap[WinLevel];
+export type WinLevelAlias = WinLevelData['alias'];
diff --git a/apps/rail-drop/src/hooks.server.ts b/apps/rail-drop/src/hooks.server.ts
new file mode 100644
index 000000000..53222ebe7
--- /dev/null
+++ b/apps/rail-drop/src/hooks.server.ts
@@ -0,0 +1,8 @@
+import { dev } from '$app/environment';
+
+export function handle({ event, resolve }) {
+ if (dev && event.url.pathname === '/.well-known/appspecific/com.chrome.devtools.json') {
+ return new Response(undefined, { status: 404 });
+ }
+ return resolve(event);
+}
diff --git a/apps/rail-drop/src/i18n/i18nDerived.ts b/apps/rail-drop/src/i18n/i18nDerived.ts
new file mode 100644
index 000000000..811452d5e
--- /dev/null
+++ b/apps/rail-drop/src/i18n/i18nDerived.ts
@@ -0,0 +1,9 @@
+import { stateI18nDerived } from 'state-shared';
+import { i18nDerived as i18nDerivedUiPixi } from 'components-ui-pixi';
+import { i18nDerived as i18nDerivedUiHtml } from 'components-ui-html';
+
+export const i18nDerived = {
+ ...i18nDerivedUiPixi,
+ ...i18nDerivedUiHtml,
+ home: () => stateI18nDerived.translate('HOME'),
+};
diff --git a/apps/rail-drop/src/i18n/messagesMap/en.ts b/apps/rail-drop/src/i18n/messagesMap/en.ts
new file mode 100644
index 000000000..0d26bf929
--- /dev/null
+++ b/apps/rail-drop/src/i18n/messagesMap/en.ts
@@ -0,0 +1,3 @@
+export default {
+ HOME: 'HOME',
+};
diff --git a/apps/rail-drop/src/i18n/messagesMap/index.ts b/apps/rail-drop/src/i18n/messagesMap/index.ts
new file mode 100644
index 000000000..fb0f0075d
--- /dev/null
+++ b/apps/rail-drop/src/i18n/messagesMap/index.ts
@@ -0,0 +1,11 @@
+import { mergeMessagesMaps } from 'utils-shared/i18n';
+import { messagesMap as messagesMapUiPixi } from 'components-ui-pixi';
+import { messagesMap as messagesMapUiHtml } from 'components-ui-html';
+
+import en from './en';
+import zh from './zh';
+
+const messagesMapGame = { en, zh };
+const messagesMap = mergeMessagesMaps([messagesMapGame, messagesMapUiPixi, messagesMapUiHtml]);
+
+export default messagesMap;
diff --git a/apps/rail-drop/src/i18n/messagesMap/zh.ts b/apps/rail-drop/src/i18n/messagesMap/zh.ts
new file mode 100644
index 000000000..ad40c9626
--- /dev/null
+++ b/apps/rail-drop/src/i18n/messagesMap/zh.ts
@@ -0,0 +1,3 @@
+export default {
+ HOME: '主页',
+};
diff --git a/apps/rail-drop/src/routes/+layout.svelte b/apps/rail-drop/src/routes/+layout.svelte
new file mode 100644
index 000000000..a3077c987
--- /dev/null
+++ b/apps/rail-drop/src/routes/+layout.svelte
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+ (showLoader = true)} />
+
+{#if showLoader}
+
+{/if}
+
+{@render props.children()}
diff --git a/apps/rail-drop/src/routes/+layout.ts b/apps/rail-drop/src/routes/+layout.ts
new file mode 100644
index 000000000..a72344cb8
--- /dev/null
+++ b/apps/rail-drop/src/routes/+layout.ts
@@ -0,0 +1,3 @@
+export const prerender = true;
+export const ssr = false;
+export const trailingSlash = 'ignore';
diff --git a/apps/rail-drop/src/routes/+page.svelte b/apps/rail-drop/src/routes/+page.svelte
new file mode 100644
index 000000000..5bf03f4a7
--- /dev/null
+++ b/apps/rail-drop/src/routes/+page.svelte
@@ -0,0 +1 @@
+
diff --git a/apps/rail-drop/src/stories/data/sampleBooks.ts b/apps/rail-drop/src/stories/data/sampleBooks.ts
new file mode 100644
index 000000000..56c5cf7c2
--- /dev/null
+++ b/apps/rail-drop/src/stories/data/sampleBooks.ts
@@ -0,0 +1,63 @@
+/* Sample books for Storybook / manual playback without a live RGS.
+ *
+ * Mechanics:
+ * Ball rolls along rail, drops into one of 3 plinko zones:
+ * Zone 1: 8 rows — conservative multipliers
+ * Zone 2: 12 rows — medium variance
+ * Zone 3: 16 rows — extreme multipliers
+ * slotIndex = number of R's in path (0 = leftmost, rows = rightmost)
+ * finalWin.amount is in cents (bet = 100c)
+ */
+
+import type { BookEvent } from '../../game/typesBookEvent';
+
+// Zone 1, low risk, center slot → 0.5× (loss) — medium risk
+export const bookZone1Loss: BookEvent[] = [
+ { index: 0, type: 'drop', zone: 1, riskLevel: 'medium',
+ path: ['R','L','R','L','R','L','R','L'], slotIndex: 4, multiplier: 0.4 },
+ { index: 1, type: 'finalWin', amount: 40 },
+];
+
+// Zone 1, low risk, edge slot → 5.6×
+export const bookZone1Edge: BookEvent[] = [
+ { index: 0, type: 'drop', zone: 1, riskLevel: 'low',
+ path: ['L','L','L','L','L','L','L','L'], slotIndex: 0, multiplier: 5.6 },
+ { index: 1, type: 'finalWin', amount: 560 },
+];
+
+// Zone 2, medium risk, center → 0.3×
+export const bookZone2Center: BookEvent[] = [
+ { index: 0, type: 'drop', zone: 2, riskLevel: 'medium',
+ path: ['R','R','L','R','L','R','L','L','R','L','R','L'], slotIndex: 6, multiplier: 0.3 },
+ { index: 1, type: 'finalWin', amount: 30 },
+];
+
+// Zone 2, high risk, left edge → 170×
+export const bookZone2BigWin: BookEvent[] = [
+ { index: 0, type: 'drop', zone: 2, riskLevel: 'high',
+ path: ['L','L','L','L','L','L','L','L','L','L','L','L'], slotIndex: 0, multiplier: 170 },
+ { index: 1, type: 'finalWin', amount: 17000 },
+];
+
+// Zone 3, medium risk, near-edge → 41×
+export const bookZone3MedWin: BookEvent[] = [
+ { index: 0, type: 'drop', zone: 3, riskLevel: 'medium',
+ path: ['L','L','L','L','L','L','L','L','L','L','L','L','L','L','L','R'], slotIndex: 1, multiplier: 41 },
+ { index: 1, type: 'finalWin', amount: 4100 },
+];
+
+// Zone 3, high risk, right edge → 1000× (MAX WIN)
+export const bookZone3MaxWin: BookEvent[] = [
+ { index: 0, type: 'drop', zone: 3, riskLevel: 'high',
+ path: ['R','R','R','R','R','R','R','R','R','R','R','R','R','R','R','R'], slotIndex: 16, multiplier: 1000 },
+ { index: 1, type: 'finalWin', amount: 100000 },
+];
+
+export const sampleBooks = {
+ zone1Loss: bookZone1Loss,
+ zone1Edge: bookZone1Edge,
+ zone2Center: bookZone2Center,
+ zone2BigWin: bookZone2BigWin,
+ zone3MedWin: bookZone3MedWin,
+ zone3MaxWin: bookZone3MaxWin,
+};
diff --git a/apps/rail-drop/static/favicon.svg b/apps/rail-drop/static/favicon.svg
new file mode 100644
index 000000000..4bfbcc818
--- /dev/null
+++ b/apps/rail-drop/static/favicon.svg
@@ -0,0 +1,26 @@
+
diff --git a/apps/rail-drop/static/loader.gif b/apps/rail-drop/static/loader.gif
new file mode 100644
index 000000000..4f94b9909
Binary files /dev/null and b/apps/rail-drop/static/loader.gif differ
diff --git a/apps/rail-drop/static/stake-engine-loader.gif b/apps/rail-drop/static/stake-engine-loader.gif
new file mode 100644
index 000000000..0a2e02ef2
Binary files /dev/null and b/apps/rail-drop/static/stake-engine-loader.gif differ
diff --git a/apps/rail-drop/svelte.config.js b/apps/rail-drop/svelte.config.js
new file mode 100644
index 000000000..8c4df3cd9
--- /dev/null
+++ b/apps/rail-drop/svelte.config.js
@@ -0,0 +1,4 @@
+// @ts-ignore
+import config from 'config-svelte';
+
+export default config();
diff --git a/apps/rail-drop/tsconfig.json b/apps/rail-drop/tsconfig.json
new file mode 100644
index 000000000..6f0ea0c09
--- /dev/null
+++ b/apps/rail-drop/tsconfig.json
@@ -0,0 +1,5 @@
+{
+ "extends": "config-ts/base.json",
+ "include": ["."],
+ "exclude": ["dist", "build", "node_modules"]
+}
diff --git a/apps/rail-drop/vite.config.js b/apps/rail-drop/vite.config.js
new file mode 100644
index 000000000..349ca771e
--- /dev/null
+++ b/apps/rail-drop/vite.config.js
@@ -0,0 +1,4 @@
+// @ts-ignore
+import config from 'config-vite';
+
+export default config();