# Porting SCROLL MILL to Roblox This document is the handoff from the browser concept to Roblox Studio. The browser was written at Roblox scale on purpose, so the port keeps the split instead of translating a physics engine: ``` src/shared/ (pure JS) config · feed · rules · rivals · rng │ ┌─────────────────┴─────────────────┐ │ │ src/web/ (three.js) roblox/src/shared/ (Luau) render, input, HUD, audio Config · Feed · Run · R6Rig show state, never own it Treadmill · Remotes server/client scaffold ``` The authoritative rules are **`src/shared/run.js`** and its mirror **`roblox/src/shared/Run.luau`**. Neither file knows about rendering, input devices, DOM or Roblox services. If you change the game, change both and run the parity test. ## Status: what is actually verified | Piece | Status | | --- | --- | | `Config.luau` / `Feed.luau` / `Run.luau` | Parse with standalone Luau; **numerically match JS** via `tools/luau-parity.mjs` | | `R6Rig.luau` | Parses; uses `HumanoidDescription` + Motor6D `Transform`; not Studio-playtested | | `Treadmill.luau` | Parses; primitive build with SurfaceGui + moving stripe parts; not Studio-playtested | | `GameService.luau` / `GameClient.client.luau` | Parse; reference scaffold only; not run in a live DataModel | | Browser game | Runs in Chromium, unit-tested, mobile + desktop screenshot QA | There is **no `.rbxl` place file and no Roblox runtime in this workspace**. Everything below marked “scaffold” should be opened in Studio and treated as reviewed starting code, not production. ## 1. Units, axes and scale - **1 three.js unit = 1 Roblox stud.** No conversion. The treadmill is 6.6 × 10.8 studs; the avatar is 5 studs tall; the belt top is 0.73 studs. - Y is up on both platforms. Seconds everywhere. - Browser avatar faces **-Z**, matching a Roblox rig's default forward. - The player's `HumanoidRootPart` sits about 3 studs above the feet. Place a player character at `beltTop + 3 = 3.73` studs above the floor. - The browser treadmill faces -Z and the camera sits behind at +Z. Keep that orientation in Studio so camera math and feed-screen placement transfer. - Do **not** copy the metre conversion from older projects. This game is studs-first. ## 2. The mirror map | Browser | Roblox | Notes | | --- | --- | --- | | `src/shared/config.js` | `roblox/src/shared/Config.luau` | Same values. Change together. | | `src/shared/feed.js` | `roblox/src/shared/Feed.luau` | 30 cards, same ids, same LCG + shuffle | | `src/shared/rng.js` | folded into `Feed.luau` | 32-bit LCG `state = (state*1664525 + 1013904223) % 2^32` | | `src/shared/rivals.js` | `Config.RivalDefs` | Names, looks, pace, phase | | `src/shared/run.js` | `roblox/src/shared/Run.luau` | `create`, `scroll`, `like`, `setGrass`, `step`, `scoreOf`, `rankOf`, `drainEvents` | | `src/web/rig.js` | `R6Rig.luau` | Real R6 `HumanoidDescription` instead of procedural meshes | | `src/web/world.js` treadmill | `Treadmill.luau` | Primitives + SurfaceGui + real stripe parts | | `src/web/feedScreen.js` | not built in 3D yet | SurfaceGui plan below; client scaffold uses a 2D card | | `src/web/hud.js` | `GameClient.client.luau` HUD | ScreenGui; design values below | | `src/web/input.js` | `GameClient.client.luau` input | ContextActionService/UserInputService | | `src/web/audio.js` | Studio sound assets | Procedural oscillator cues need re-authoring | | `src/web/engine.js` bloom | Lighting + BloomEffect | No effect files ship here | The browser's rules are event-driven: ```js // push, never call UI from the rules state.events.push({ type: "banger", title, combo, hype, doom }); ``` The Luau mirror does the same. The server drains events into snapshots; the client turns them into sounds and toasts. Keep that boundary intact when you add modes. ## 3. Server authority Run the simulation on the server. The client sends intents and renders the latest snapshot; it never reports speed, distance, doom, score or card likes. ### Fixed tick `GameService.luau` accumulates Heartbeat time and steps `Run.step` at **60 Hz**, with a small catch-up cap: ```lua local FIXED = 1 / 60 session.accumulator += math.min(dt, 0.1) while session.accumulator >= FIXED and steps < 5 do Run.step(state, FIXED) session.accumulator -= FIXED steps += 1 end ``` Snapshots at **10 Hz**; visual pose updates at frame rate. If you add multiplayer leaderboard features, keep the same fixed tick so a 144 Hz client does not get an advantage. ### Remote contract Folder: `ReplicatedStorage.ScrollMillRemotes` (created by `GameService.luau`). | Remote | Direction | Payload | | --- | --- | --- | | `Action` | client → server | `action: string, active: boolean, seq: number` | | `Snapshot` | server → client | full state snapshot, 10 Hz | | `RunStarted` | server → client | snapshot + `podOrigin: Vector3` | | `RunEnded` | server → client | summary + final events | Actions: `scroll`, `like`, `grass_start`, `grass_stop`, `restart`. Validation in the scaffold: - `typeof(action) == "string"`, `typeof(active) == "boolean"` (defaults false). - 35 actions per rolling second are accepted; `Run`'s cooldowns (0.1 s scroll, 0.22 s like) do the rest. - Restart is only accepted while `state.over`. Add a sequence check if you want replay/audit; the client already increments `seq`. Do not trust the client's timestamp or cooldown. ### Snapshot shape See `roblox/src/shared/Remotes.luau`, which documents every field. Key fields: ```lua { version = 1, seed, time, distance, speed, hype, doom, combo, bestCombo, sessionLeft, scrolls, bangers, baits, score, rank, grassing, over, won, reason, current = { id, tier, title, caption, author, tag, sound, art, badge, likes, accent, accent2, liked }, upcoming = { ...card, ...card }, rivals = { { id, name, progress, speed, loggedOff, finishTime }, ... }, events = { ... drained since the last snapshot ... }, podOrigin = Vector3 -- RunStarted only } ``` The client should treat `current` as read-only presentation data. If the client predicts scroll/like for responsiveness, reconcile with the next snapshot and never write score locally. ## 4. The real player on the belt `GameService.attachPlayerCharacter()` pins the player's real R6 character: 1. Wait for `Humanoid` and `HumanoidRootPart`. 2. `WalkSpeed = 0`, `JumpPower = 0`, `AutoRotate = false`, hide name/health. 3. Anchor `HumanoidRootPart` and `PivotTo` the pod's lane at `Vector3(podX, 3.73, 0)`. 4. Drive the six R6 Motor6Ds with the shared pose function. This depends on the place using **R6 avatars** (Game Settings → Avatar → Rig Type: R6). For R15: - R15 joint names differ (`LeftShoulder`, `LeftElbow`, `LeftHip`, `LeftKnee`, …). Build a mapping table. - Better, use an `Animator` with an uploaded run cycle and drive `Humanoid:Move` on a treadmill-local axis, or use `AnimationTrack:AdjustSpeed` proportional to `state.speed`. - The simulation is rig-agnostic; only the pose adapter hates you. NPC rivals use `R6Rig.create()`, which calls `Players:CreateHumanoidModelFromDescriptionAsync`. **It yields**, so create pods in `task.spawn`, never in the Heartbeat loop. If you reuse the pod on respawn, make sure the new character is reattached (the scaffold listens to `CharacterAdded`). ### R6 joint mapping | Motor6D | Part0 → Part1 | Browser joint equivalent | | --- | --- | --- | | `RootJoint` | HumanoidRootPart → Torso | torso lean/bob | | `Neck` | Torso → Head | head bob/jitter | | `Left Shoulder` | Torso → Left Arm | `armL.rotation.x` | | `Right Shoulder` | Torso → Right Arm | `armR.rotation.x` | | `Left Hip` | HumanoidRootPart → Left Leg | `legL.rotation.x` | | `Right Hip` | HumanoidRootPart → Right Leg | `legR.rotation.x` | `R6Rig.poseRunner()` uses `Motor6D.Transform` with `CFrame.Angles` and a small RootJoint translation for bounce. The browser and Luau run cycles use the same phase formula: ```lua phase += dt * (5.2 + speed * 0.38) -- run phase += dt * 2.2 -- grass walk swing = sin(phase) * 0.9 * min(1.25, speed / 20) ``` Winning raises the shoulders; losing slumps the torso and tilts the neck. Do not use the character's `Humanoid` for locomotion while the shared simulation owns distance. ### The player's avatar look The browser uses a classic R6 noob palette for the player: head/arms `#f5cd30`, torso `#111118`, legs `#2f2f3a`, accent `#c8ff2e`, black headphones, and a small backpack with a neon spine light. Rivals use `Config.RivalDefs`. On Roblox, the real player keeps their Roblox avatar by default. If you want the concept's uniform, use `player:GetHumanoidDescriptionFromUserIdAsync` / `Players:GetHumanoidDescriptionFromUserId` and apply it to the character, or build a clone with `R6Rig.create` and hide the real character. The scaffold leaves the player's avatar intact and adds a name tag; decide before shipping cosmetics. ## 5. The treadmill `Treadmill.luau` builds: - base, belt, rails, neon rail strips, console post, console body - a `SurfaceGui` console with speed, label and a progress fill - a back tag reading `SCROLL MILL` (player) or the rival name - 14 thin neon stripe parts that scroll and wrap to sell belt motion - a neon underglow part + `PointLight` - `treadmill:update(speed, distance, dt)` to drive all of the above The browser model's belt is 4.8 × 0.16 × 9.2 studs, top at 0.73, and the treadmill is 6.6 wide × 10.8 long. Keep those numbers if you want the camera and player pod positions to match. ### Pod layout The server creates one pod per player at `x = podIndex * 64`: | Lane | X (relative) | Z | | --- | --- | --- | | Player | 0 | 0 | | Rival 1 (BaconHair_09) | -9.3 | -1.2 | | Rival 2 (guest_1337) | +9.3 | -1.2 | | Rival 3 (MoonberryMia) | -17.5 | -10 | | Rival 4 (xX_Noob_Xx) | +17.5 | -10 | The browser camera is at `podOrigin + (0, 8.8, 14.8)` looking at `podOrigin + (0, 6.35, -7.6)` with a 52° FOV. Those are good starting values in Studio; add the speed FOV and shake in a custom camera script, not on the default Roblox camera. ## 6. The feed phone (the big missing visual) The browser's centerpiece is a giant phone-like media wall above the belt. The port scaffold only renders a 2D card in the HUD; building the 3D screen is the largest remaining visual task. ### Suggested build - 9.6 × 16.6 studs frame, 8.5 × 15.45 studs screen, at roughly `(podOrigin.X, 9.55, podOrigin.Z - 13.6)`, tilted -1°. - A `SurfaceGui` on the screen face with `SizingMode = PixelsPerStud` around 80–90 and `LightInfluence = 0`; or a `ViewportFrame` with a 2D UI inside. - Parent a `PointLight` to the screen (accent color, range ~30) and a second cool fill so the phone lights the avatar. - The browser draws at 900 × 1660 px. Use the same aspect ratio so captions, the title, the like rail and the bottom nav land in the same places. ### Layout spec (from `src/web/feedScreen.js`) | Region | Fraction of height | Content | | --- | --- | --- | | Status bar | 0–5% | fake time, `SCROLL MILL · FEED v9.2`, battery | | Tabs | 5–9% | Following / For You, LIVE pill | | Card | 10–84% | gradient art, badge, big title, caption, author, sound, tag/likes, right action rail | | Bottom nav | 86–100% | Home, Friends, +, Inbox, You | | Footer | last 2% | “an original satire feed · no third-party media” | Map snapshot fields directly: - `current.badge` + `current.tier` → badge color (banger = orange, bait = pink, neutral = mint). **Never print the word “bait”.** The badge, like count and vibe are the tell. - `current.title` → Barlow Condensed-ish display font, uppercase, 3 lines max. - `current.caption`, `author`, `tag`, `likes`, `sound`. - `current.accent` / `accent2` → gradient and phone glow color. - `current.liked` → filled heart and the like burst. - `doom > 68` → red scanlines/watermark; `grassing` → green overlay + “TOUCHING GRASS”. - Card changes: slide up over ~0.25 s. The incoming card's title is enough; do not animate 60 different properties per frame. ### Art The browser draws each card's art procedurally (`pumpkin`, `moon`, `disco`, `cat`, `coffee`, `ghost`, `skeleton`, `sunset`, `map`, `football`, `smile`, `grass`, `calendar`, `shapes`, `fan`, `gum`, `mic`, `link`, `play`, `desk`, `brain`, `craft`, `bag`, `leaf`, `tea`, `book`, `wave`). For Roblox, the cheapest faithful path is a set of original Decal/ImageLabel assets generated from the same canvas code, or low-poly 3D props on a ViewportFrame. Do **not** ship TikTok screenshots or third-party clips. See §8. ## 7. HUD, camera and effects The browser HUD is DOM; the Roblox version is a `ScreenGui`. Match the grouping, not the pixels: - Top left: brand and LIVE RUN. - Top center: goal progress, “LOG OFF IN N” and a 0–100% bar. - Top right: session seconds, rank `N/5`, sound, pause. - Left: HYPE bar, DOOM bar, combo; coach line under it. - Right: big speed, 5-row leaderboard (YOU + 4 rivals), CLOUT. - Bottom: key hints / touch controls; mobile card. - Toasts: right side; keep at most 4 alive. - End screen: result (`YOU LOGGED OFF` / `THE FEED KEPT YOU` / `SESSION EXPIRED`), CLOUT, rank title, four stats, algorithm quote, Run It Back / Share. - Mobile: same data, compressed. A non-negotiable in the browser is the readable mobile card above the controls, because the 3D phone is small on a 390 px portrait screen. Keep that idea. Camera: - FOV 52° at 16:9; +up to 7° with speed; pull back on mobile. - Shake only when doom > 70 or on a punch event. Do not shake the Roblox default camera; use a scriptable camera part/attachment with a spring. - The browser additively blends a neon bloom, fog, dust points and speed lines. In Roblox, use `Lighting` (Future or ShadowMap), `BloomEffect`, neon Parts, `ParticleEmitters`, and `Atmosphere`. Keep bloom modest on mobile. - The browser's floor grid scrolls with distance; the arches are a repeating group offset by `distance % 34`. The Roblox equivalent is a scrolling texture or a recycled set of arch models. Audio: - Browser cues are synthesized WebAudio: scroll noise, banger chord, bait descending buzz, heartbeat above 72 doom, grass swell, win arpeggio, lose descent, low treadmill hum tied to speed. - For Roblox, either upload short original sounds or synthesize with `SoundService`/`AudioEmitter` features if available. Do not reuse the browser's temporary oscillator design as a shipping audio plan; source original assets and respect Roblox audio permissions. ## 8. Content and policy notes The feed is **original parody content**: - No real TikTok videos, sounds, creators, usernames or screenshots. - Card titles and captions are written for this project. - The “likes” counts are fictional strings. - The feed is labeled `SIMULATED FEED` / `NO REAL VIDEOS` in the UI and README. - Trend formats are used as *shapes* (Halloween transition, dramatic walk, October montage, reali-tea, ceiling-fan how-to), not copied media. Roblox's 2026 guidance for Kids/Select accounts targets media feeds that combine (a) short-video/image/post feeds, (b) autoplay/endless scroll/automatic looping, and (c) rewards that require or suggest continuous viewing. This game deliberately: - advances only on player scroll, - punishes scrolling with doom, - rewards leaving with a win bonus, - has no real feed and no autoplay. That inversion is the point of the concept, but **it is not a policy certification**. Review the current Roblox community standards and Kids/Select requirements before publishing, especially if you add real media, autoplay or account-linked rewards. If you build the Roblox version for a young audience, prefer even softer timers, no dark-pattern copy, and a clear “log off” real-world message. ## 9. Persistence The browser stores only the local best score: ```js localStorage["scrollmill.best.v1"] ``` For Roblox, use a DataStore per player: ```lua local store = DataStoreService:GetDataStore("ScrollMillBest_v1") local key = "u_" .. player.UserId -- save max(best, state.score) on run end / PlayerRemoving ``` `GameService.luau` includes a best-effort version with `pcall` so Studio without API access does not error. Suggested schema: ```lua { bestScore = number, bestDistance = number, bestCombo = number, runs = number, lastSeed = number, updatedAt = number, -- os.time() } ``` Never store display names or free text. If you add a global leaderboard, use an `OrderedDataStore` or a web service with server-side validation and a privacy note; do not trust client-submitted scores. ## 10. Anti-cheat / correctness - The client never sends distance, speed, score or card tier. It sends only action names and a boolean for grass. - Every action goes through `Run.scroll/like/setGrass`, which enforce cooldowns, card state and doom. - Rate-limit at the remote; add a monotonically increasing `seq` check if you care about replay. - The server's fixed 60 Hz step makes the outcome independent of client FPS. - If you add prediction, reconcile on every snapshot and clamp the client's predicted distance/doom to the server values. ## 11. Performance - The browser keeps draw calls modest by sharing geometries/materials and drawing the feed at ~24 fps into a canvas texture. In Roblox, the SurfaceGui is cheap; avoid recreating card UI from scratch every frame. Rebuild only on card change and update numbers at 10 Hz. - NPC rigs are the expensive part; use `R6Rig.create` only once per pod, not per frame. `CreateHumanoidModelFromDescriptionAsync` yields. - Use 4–6 NPC rivals near the player. Cull far pods (`MaxDistance`, `StreamingEnabled` if appropriate). - The 14 belt stripe parts per treadmill are acceptable; if you have many pods, switch to a scrolling `Texture`/`SurfaceGui` belt and hide the parts. - Keep the phone's PointLight limited (one per pod) and avoid many real-time shadows. ## 12. Acceptance checklist Before calling the port playable: - [ ] R6 avatars enforced; player character pinned at `beltTop + 3`, no falling, no default movement, camera behind. - [ ] Fixed 60 Hz server tick; 10 Hz snapshots; no client-owned score. - [ ] Feed order matches the browser for the same seed (run `node tools/luau-parity.mjs`). - [ ] Goal 1,800, session 105 s, doom 100, win/lose/end screen. - [ ] Scroll/like/grass actions all work from keyboard, gamepad (optional) and touch; grass is hold-to-drain and releases on focus loss. - [ ] Bait is never labeled “bait”; badges/likes/vibe are the tell. - [ ] The feed phone is readable from the chase camera at 1280 × 720 and not cropped by the HUD at 390 × 844. - [ ] Mobile HUD has the readable current-card strip, three touch controls and safe-area padding. - [ ] No real third-party media; the simulated-feed label is visible. - [ ] Best score persists (DataStore) and a new server session resets the run. - [ ] Studio playtest with 2+ players; pods do not overlap; remotes rate-limit. - [ ] Policy review for the target age group. ## 13. Suggested next milestones 1. Build the 3D feed phone as a `SurfaceGui` from the snapshot; match the browser layout, then add the card slide and doom glitch. 2. Decide the avatar plan: keep real avatars or apply the concept uniform. 3. Replace the 2D HUD scaffold with a real ScreenGui matching `styles.css` values (lime `#c8ff2e`, pink `#ff2e88`, cyan `#2ee6ff`, cream `#f5f2e8`, ink `#06060a`). 4. Add a server-wide leaderboard of logged-off runs and a “pods logged off” number above the feed wall. 5. Add original sound assets and a mute setting. 6. Studio playtest at 30, 60 and 120 FPS, mobile portrait and landscape. 7. Publish a private place, run policy review, then consider a public launch. ## 14. Parity test details `tools/luau-parity.mjs`: - copies `Config.luau`, `Feed.luau`, `Run.luau` to `.parity/`; - rewrites only `require(script.Parent.Config)` / `require(script.Parent.Feed)` to `require("./Config")` / `require("./Feed")` so the standalone Luau CLI can load them; - runs a fixed 720-step autopilot with seed 123 and draws 20 feed cards with seed 7; - compares feed ids and every numeric result against the JavaScript side. Expected output includes: ``` PASS feed order matches for seed 7 PASS distance parity PASS speed parity PASS hype parity PASS doom parity PASS banger count parity PASS best combo parity PASS score parity PASS rank parity PASS end state parity ``` If a value drifts, the usual causes are: a constant changed in one Config but not the other, a rounding change, or someone “fixing” the LCG. The LCG must stay `state = (state * 1664525 + 1013904223) % 4294967296` and both deck shuffles must stay Fisher–Yates from the end. ## 15. Files in the port kit ```text roblox/ default.project.json README.md src/shared/Config.luau src/shared/Feed.luau src/shared/Run.luau src/shared/R6Rig.luau src/shared/Treadmill.luau src/shared/Remotes.luau src/server/GameService.luau src/server/Main.server.luau src/client/GameClient.client.luau ``` The shared files are plain Luau with `script.Parent` requires; Rojo maps them into `ReplicatedStorage.Shared`. The server and client files are scaffolds and comments say so. Use `rojo serve` from `roblox/` to sync, or copy the folders manually. --- If something here disagrees with the code, the code and `tools/luau-parity.mjs` win. Fix the doc, then fix the other platform.