Pulse Runner is a 3-lane endless runner built in Lens Studio 5.22 for Snapchat, starring the user's Bitmoji. You move between lanes to dodge the tall obstacles and jump the short ones, picking up coins and power-ups along the way, while a friend's Bitmoji chases you and the world keeps speeding up. Three hits end the run; you can restart or return to the menu without leaving the Lens. Three switchable visual themes re-skin the whole game.
This is a prototype. The goal is a readable, modular codebase with a sensible Lens Studio setup, not a finished product.
| Input | Action |
|---|---|
| Swipe left / right | Move one lane |
| Tap | Jump (clears short obstacles) |
| On-screen icons | Start · Restart · Menu · Mute · Theme |
In Lens Studio's Preview the mouse stands in for touch: click is a tap, click-drag is a swipe.
This repo keeps its binary assets (models, textures, audio, packages) in Git LFS. Run
git lfs installonce before cloning, so you pull the real files instead of small pointer stubs.
- Open Lens Studio 5.22+ and open
PulseRunner.esproj. - The Preview panel plays the Lens automatically. If the webcam source fails, switch the Preview input to a sample image or video.
- The Bitmoji shows a generic placeholder avatar in the editor. The real user's Bitmoji (and the friend chaser) only resolve on device, which is expected.
The editor preview can't show everything. Real Bitmoji, multi-touch, real audio and the chaser/leaderboard only behave correctly on a device.
- Log in to Lens Studio with the same Snapchat account you use on the phone.
- Click Preview Lens in the top toolbar; a Snapcode appears. In Snapchat, point the camera at it and press-and-hold to scan, which pairs the phone. Pairing is a one-time step.
- Once paired, the Lens previews live on the phone and updates as you edit. To share it beyond preview, use Publish.
- There are no
print()logs on device; use Snapchat's developer logging or a temporary on-screenTextfor debugging. The first Bitmoji load can briefly show the placeholder while it streams.
- Runner core: 3 lanes, a jump arc, world-scrolling obstacles and coins, object pooling.
- Two obstacle types: dodge the tall ones by switching lane, jump the short ones (color-coded by theme).
- Lives + Game Over: 3 hits ends the run; red hit-flash and a camera shake.
- Score: coins add points, shown on the HUD; the high score persists between sessions.
- Difficulty ramp: world speed and obstacle density rise over time, including two-lane walls.
- Power-ups: a shield (absorbs a hit) and a magnet (pulls coins); plus a boost that briefly speeds you up with speed-lines.
- Chaser: a My Friend Bitmoji trails you a fixed distance behind, stays in view,
and closes in on each hit, then catches up on game over. It needs at least one friend
on the account to load (the editor shows a placeholder). Tune the resting distance with
ChaserController.restGap. - 3 themes: NEON / CANDY / COSMIC. A start-menu button re-skins the road, obstacles, coins and UI live, and the choice persists.
- Pause: an in-game pause/resume button freezes the run (world and animation) and surfaces restart / menu without ending it.
- Leaderboard: submits the score and shows friends' top scores on game over (device-only; guarded to no-op in the editor).
- Audio: coin / hit / jump / power-up / shield-block / start / game-over SFX, a background music loop and a mute toggle. The mute choice persists.
- Icon UI: start menu, on-screen restart / menu / mute / pause / theme buttons, heart lives, a dimmed menu backdrop.
Small single-responsibility script components coordinate through a central
GameManager and a few shared modules. Most cross-talk is event-driven (systems
subscribe to the hub); a handful of direct calls remain where a hard dependency is
clearer than an event, such as a spawner applying a buff or a controller playing a SFX.
Assets/Script/
├── Core/
│ ├── GameManager.ts # State machine (Ready/Playing/GameOver) + pause, lives,
│ │ # score, shared world speed + difficulty, event hub, high score
│ ├── Lanes.ts # Single source of truth for lane layout (count, spacing, lane-to-X)
│ ├── ThemePalette.ts # 3 themes + current-theme state + listener bus (getTheme/cycleTheme)
│ └── LeaderboardController.ts # Submits score + shows friends' top scores (device-only)
├── Player/
│ ├── InputController.ts # Raw touch to gestures (tap / swipe L/R); a UI tap can consume it
│ ├── PlayerController.ts # Lane position + jump arc; tap = jump / start
│ ├── CharacterAnimator.ts # Plays Bitmoji run/idle/jump/defeated clips from game state
│ ├── PlayerBuffs.ts # Shield / magnet / boost state + timers (single owner)
│ └── PlayerBuffsVFX.ts # Floating shield/magnet indicator above the player
├── World/
│ ├── ObstacleSpawner.ts # Pooled obstacles (tall=dodge / short=jump), movement, ramp, walls
│ ├── PickupSpawner.ts # Pooled coins, radius magnet pull, collection adds score
│ ├── PowerUpSpawner.ts # Pooled power-ups (shield/magnet/boost models), applies PlayerBuffs
│ ├── CollisionSystem.ts # World-space hit test reported to GameManager (shield absorbs)
│ └── ChaserController.ts # Pursuer transform: trails, closes in on hit, catches up, mirrors jump
├── UI/
│ ├── UIController.ts # HUD (score) + banner text + button wiring + menu backdrop
│ ├── ScreenButton.ts # Base for tap buttons: hit-test + claim the tap from input
│ ├── UIButton.ts # Generic icon button with onClick (start / restart / menu)
│ ├── MuteButton.ts # Mute toggle icon (speaker on/off)
│ ├── PauseButton.ts # In-game pause / resume toggle
│ ├── ThemeButton.ts # Cycles themes, persists choice
│ └── LivesDisplay.ts # Heart icons reflecting lives
├── Visual/
│ ├── ThemedVisual.ts # Tints a prop (road/lane line) from the current theme, re-tints live
│ ├── HitFlash.ts # Full-screen red damage flash
│ ├── ScorePopup.ts # "+N" popup on coin pickup
│ ├── SpeedLines.ts # Radial speed-lines overlay during boost
│ └── CameraShake.ts # Camera-shake juice on hit
└── Audio/
└── AudioController.ts # Single SFX + music hub; subscribes to GameManager events; mute flag
- GameManager as the hub. It owns the state machine, lives, score and the shared
world speed, and broadcasts
onLivesChanged/onScoreChanged/onStateChanged. Systems depend on the hub, not on each other, so they reset and react independently. - The world moves, not the player. The player only changes lane (X) and jumps (Y); obstacles and coins scroll toward the camera and recycle. Collisions become a cheap world-space distance check.
- Single sources of truth. Lane geometry lives in
Lanesand world speed inGameManager; the look lives inThemePalette. Change one constant to retune. - Object pooling. Obstacles use runtime
prefab.instantiate; coins and power-ups use pre-placed child pools. Two patterns are shown on purpose, with no per-spawn allocation. - Runtime theme switching. Consumers read
getTheme()and subscribe toonThemeChanged(); one button re-skins everything at once. - Decoupled audio and juice.
AudioController,HitFlash,CameraShakeandSpeedLinesonly listen to game events; gameplay code doesn't know they exist. - Restart without leaving the Lens. The spawners reset themselves on the state-change event, and so do the player and the chaser; no central "reset everything" routine couples them.
- The friend chaser and leaderboard behave fully only on device; the editor shows a placeholder avatar.