A timed side-scrolling platformer built with JavaScript and p5.js.
A small patchwork ghost has twenty seconds to find four coloured apples, in an order the game picks at random, and make it back to its cabin before the clock runs out. Canyons cut through the looping landscape, three lives stand between you and a game over, and the sky above slowly turns from bright daylight to a starry, planet-filled night as the timer ticks down.
Ghost Run was my final game project for Introduction to Programming I at the University of London. It is written in plain JavaScript with p5.js and p5.sound, and every visual in the game is drawn in code.
Play in your browser · View on GitHub · Run it locally
How to Play · Gallery · Features · How It Works · Visual Design · Development Journey · Getting Started · Repository · Limitations & Lessons · Credits
Collect all four apples in the order shown in the Objectives panel, then run back to the cabin you started beside, all before the countdown reaches zero.
The collection order is shuffled at the start of every game, so the route changes each time. The Objectives panel in the top-right corner shows the colour of the apple you need next. Walking through an apple that isn't your current objective does nothing; it simply stays where it is until its turn comes. Once all four are collected, a flag appears on the cabin and the panel tells you to GET HOME QUICK.
| Action | Keys |
|---|---|
| Move left | ← or A |
| Move right | → or D |
| Jump | ↑, W or Space |
| Speed up the end-screen fade | Any key while the screen is fading |
| Play again | Space on the end screen |
| View high scores | H on the end screen |
The game is keyboard-only. Background music starts the first time you press a key, because browsers block audio until the player interacts with the page.
The clock. Every round starts with a nominal 20 seconds, and the countdown begins as soon as the game loads. The timer is shown in the top-centre panel.
Canyons. Brown canyons break up the ground. Land in one and the ghost falls out of the world, costing you a life. You can steer while you're in the air, so a well-timed running jump clears them comfortably.
Lives. You have three, shown as coloured dots next to your score. If you lose a life but still have lives left, the ghost respawns at the cabin and you lose 1,000 points. Any apples you've already collected stay collected, and the clock keeps running.
Collecting apples. Apples float just above the ground. You collect one by running through it at ground level, so jumping over an apple will carry you past it.
Scoring. Each apple is worth 100 points for every second left on the clock when you collect it. Collecting an apple with 15 seconds remaining earns 1,500 points. Because falling costs 1,000 points, a clumsy run can finish with a score below zero.
The world. The landscape scrolls around the ghost and loops back on itself, so running far enough in either direction brings you back around to the cabin.
| Outcome | How it happens | End screen shows |
|---|---|---|
| Victory | All four apples collected in order, then back to the cabin | Completion time, final score and the high-score prompt |
| Out of time | The countdown reaches zero | "You ran out of time..." |
| Out of lives | You fall into a canyon with no lives left | How long you survived |
On any end screen, press Space to play again with a newly shuffled apple order. Winning scores are added to a high-score table showing the top five results from your current browser session, with your latest score highlighted in white.
The sky is tied to the countdown, so every round travels from day, through an orange dusk, into night.
Victory. Completion time and score, with prompts to view high scores or play again.
- A looping side-scrolling world. The ghost stays centred while the landscape scrolls beneath it, wrapping around so the world never runs out.
- Jumping with gravity. A strong initial jump is gradually pulled back down by a per-frame gravity value, with full air control.
- Canyon hazards. Ten canyons are placed across the world. Falling into one costs a life and respawns the ghost at the cabin.
- Randomised objectives. Four colour-coded apples must be collected in a shuffled order that changes every game.
- Time-based scoring. The faster you collect each apple, the more it's worth.
- Lives, victory and defeat. Three lives, a win condition that requires getting home, and two separate ways to lose.
- End screens and a high-score table. Fading overlays for victory and game over, a top-five high-score screen and one-key restarts.
- A custom-drawn patchwork ghost with six poses: standing, walking left and right, and jumping straight up, left and right.
- A day-to-night sky driven by the timer, with the sun setting and the moon rising as a rotating celestial layer turns across the sky.
- Original celestial illustrations: a sun with counter-rotating rings of rays, a cratered moon, three ringed and striped planets, an orange ringed planet, a teal "habitable" planet with drifting cloud streaks, a glowing black hole with spinning rings, and 200 twinkling stars.
- Geometric scenery: faceted mountains, rounded block trees, two layers of drifting clouds, layered canyon walls and a striped log cabin that raises a flag when every apple is found.
- A shared colour palette of yellow, orange, pink, purple and teal, used across the character, cabin, HUD and sky.
- Background music that loops during play and fades down at the end of a round.
- Jump, victory and game-over sound effects.
- Thirteen different death sounds, one of which is picked at random every time the ghost falls.
Ghost Run is a single p5.js sketch. This section walks through the main systems, with short excerpts from sketch.js. The more detailed explanations are collapsed so you can open only what interests you.
A p5.js sketch is built around a few functions that the library calls for you:
| Function | When it runs | What Ghost Run does there |
|---|---|---|
preload() |
Once, before anything else | Loads all 17 sound files and sets their volumes |
setup() |
Once, after loading | Creates the 1000 × 576 canvas, sets a target of 60 frames per second and builds the world data |
draw() |
Every frame, about 60 times a second | Draws the scene, reads the keyboard, moves the ghost, updates the timer and checks for wins and losses |
keyPressed() |
Whenever a key goes down | Starts the music, handles restarts and high scores, and speeds up end-screen fades |
Because draw() repeats every frame, all movement and animation is incremental: each frame nudges a position, a rotation or a counter slightly, and the result looks like continuous motion.
Each frame, draw() works in the same order: sky and background, keyboard input, the ghost, foreground canyons and apples, the timer tick, the on-screen panels, then movement, collisions and game-state checks.
The game's state is held in a handful of Boolean flags (isLeft, isRight, isFalling, isPlummeting, winner, loser, showHighscore) and a gameOver object that groups the timer, lives, overlay opacity and sound flags. Most functions check these flags before doing anything, which is how the game stops the ghost moving once it falls into a canyon or once a round ends.
stateDiagram-v2
[*] --> Playing
Playing --> Falling: lands in a canyon
Falling --> Playing: lives left, respawn at cabin (−1,000 points)
Falling --> GameOver: no lives left
Playing --> GameOver: countdown reaches zero
Playing --> Victory: all four apples in order, then home
Victory --> HighScores: press H
Victory --> Playing: press Space
GameOver --> Playing: press Space
HighScores --> Playing: press Space
Movement, jumping and gravity
Movement is read with keyIsDown() inside a checkKeys() function that runs every frame. A comment in the source notes that this gave more immediate, less buggy movement than toggling flags in keyPressed() and keyReleased(). Arrow keys and WASD are both supported, and input is ignored while falling into a canyon or after the round ends.
Jumping is only allowed while the ghost is standing on the ground. A jump sets an upward velocity, and gravity pulls it back each frame:
// checkKeys(): start a jump from the ground
jump_Velocity = -20;
isFalling = true;
jumpSound.play();// gameChar_interaction(): gravity while in the air
else if(gameChar_y < floorPos_y)
{
jump_Velocity += 1.25;
}Every frame, gameChar_state() adds jump_Velocity to the ghost's vertical position and chooses which of the six ghost drawings to use based on the movement flags.
Scrolling the world with a scene offset
The ghost never actually moves left or right on screen. It stays at the centre of the canvas, and everything else moves around it. Every tree, canyon, apple and the cabin has a fixed world position, and each one is drawn at that position plus a single shared offset, scene_movement:
tree(trees_x[i] + scene_movement, floorPos_y, 1.0);Holding a direction key shifts the offset by 15 pixels per frame. When it passes ±2000, it jumps by 4000 in the other direction, which wraps the world:
if(scene_movement < -2000)
{
scene_movement += 4000;
}
else if(scene_movement > 2000)
{
scene_movement -= 4000;
}The canyon and tree positions are spaced so that points 4,000 pixels apart line up, and two of the apples are drawn and checked a second time at ±4000 so they remain reachable from either side of the wrap.
Canyons and losing lives
The canyons are stored as an array of objects ({x, size}), which lets the same drawing and collision functions loop over all ten of them. When the ghost is at ground level, checkCanyon() tests whether it is standing within 100 pixels of a canyon's centre. If it is, isPlummeting becomes true, a stronger gravity takes over and the player loses control.
Once the ghost has dropped below the bottom of the canvas, loseLife() removes a life and plays one of the thirteen death sounds. If lives remain, it deducts 1,000 points and resets the ghost to the cabin. If not, it sets loser to true.
Apples and the randomised objective order
The four apples are an array of objects, each with a position, size, colour and a found flag. At the start of each game, p5's shuffle() randomises the order in which they must be found:
collectable_order = shuffle([0, 1, 2, 3]);
collectable_order_index = 0;collectable_order holds the apple indices in play order, and collectable_order_index tracks how far through that list the player is. An apple only counts if it is the current objective and the ghost is close enough to it:
if(collectables[i].found == false
&& collectable_order[collectable_order_index] == i
&& dist(gameChar_x, gameChar_y, collectables[i].x + scene_movement, collectables[i].y) < collectables[i].size / 2)
{
collectables[i].found = true;
calc_score();
collectable_order_index += 1;
}The Objectives panel reads the same two variables to draw a small apple in the colour of the next target, which keeps the HUD and the rules in sync.
The countdown, scoring and the high-score table
The timer is frame-based, not a real-time clock. It starts at 1,200 and loses one each frame while a round is in progress:
if(gameOver.timer > 0 && winner == false && loser == false)
{
gameOver.timer -= 1;
}At the target rate of 60 frames per second, 1,200 frames equals 20 seconds, and the HUD displays timer / 60 as seconds. If a browser runs the sketch below 60 fps, the countdown takes longer than 20 real seconds. The game measures play in frames, not wall-clock time.
Scoring uses the same counter. Each apple adds the remaining seconds × 100:
score += round(gameOver.timer / 60 * 100)When the player wins, the score is pushed into the highscores array. That array lives only in memory: it persists across restarts within the same page session, but it resets when the page is reloaded. There is no saved data, database or online leaderboard. The high-score screen sorts the array with a pair of nested loops and displays the top five.
How the timer drives the day-to-night sky
The sky doesn't have its own clock. Every frame, drawSky() converts the remaining time into an angle using p5's map(), then uses that angle to fade two full-canvas colour washes:
sky_Spin = map(gameOver.timer, 1200, 0, 60, 210);
auburn_sky_alpha = map(sky_Spin, 105, 115, 0, 120)
night_sky_alpha = map(sky_Spin, 140, 160, 0, 255)The sun, moon, planets, black hole and stars are all drawn inside one push()/pop() block that is translated to the centre of the canvas and rotated by sky_Spin degrees. Over a full round, the whole celestial layer turns 150°, which carries the sun down behind the ground and brings the moon up into view. At the same time, the orange wash fades in around the one-third mark of the countdown, and the navy night wash follows partway through the round.
Because both the rotation and the colour changes are calculated from the timer, the sky always matches the time remaining, and resetting the timer on restart also resets the sky to daytime. Smaller animations, such as the spinning sun rays, the black hole's rings, the drifting clouds on the habitable planet, the twinkling stars and the planets' gentle bob every three seconds, run independently alongside it.
Sound: preloading and triggering
All audio is loaded in preload() so that nothing plays before it's ready. The thirteen death sounds share a naming pattern, so a loop loads them into an array:
for(var i = 0; i < 13; i++)
{
var deathSound_asset = 'assets/death_' + i + '.wav';
deathSounds[i] = loadSound(deathSound_asset);
deathSounds[i].setVolume(0.6);
}Later, loseLife() picks one at random:
var randomDeathSound = floor(random(0, 13));
deathSounds[randomDeathSound].play();Flags such as gameOver.soundPlayed and gameOver.deathSoundPlayed make sure one-off effects play once, not on every frame. The background music is started from keyPressed(), since browsers only allow audio to start after user interaction. During the ending sequence, the music's volume is gradually reduced to a quiet floor.
Restarting
restartGame() resets every piece of round state: the win and loss flags, score, timer, lives, overlay, character position, movement flags, scene offset and every apple's found flag. It also reshuffles the apple order and restores the music volume. The highscores array is deliberately left alone, so scores build up across repeated plays.
A large part of the time I spent on Ghost Run went into its visual layer. Everything on screen is built from p5.js primitives such as rectangles, ellipses, arcs, triangles, quads and a few Bézier curves. The game uses no image files.
Starting with a concept. Before writing any game logic, I built a base creative direction. It started with the ghosts from Pac-Man, a game I used to love. I wanted to keep that simplicity, so the finished game would look as clean and polished as possible without overcomplicating the design. The original concept, ColourBound, imagined a white ghost winning back its colours, and the colours of the world, by adding patchwork to itself. The finished game doesn't include that colour-restoration mechanic, but the patchwork ghost comes directly from it.
Building a colour system. Next, I settled on a colour profile that was bright but complementary. I took my time collecting reference colours online, then built them into a set in Adobe Color. From there, I made a tonal range of lighter and darker variations for each colour, so I could use them throughout my code and keep everything cohesive.
The ghost. In the finished game, the ghost is already fully patched: panels of pink, purple, teal and two yellows sit inside a rounded silhouette with a scalloped hem, cut to shape with p5's clipping mask. Short stitch marks, drawn with small loops, run along the seams between the patches. The ghost has six separate drawings, one for each combination of standing, walking and jumping, so it faces the direction it's moving.
The landscape. The scenery is flat and geometric: faceted mountains in layered blues and whites, trees built from overlapping rounded squares in several greens, clouds with grey offset shapes behind white, and canyons made of stacked earth-toned blocks. The cabin uses alternating orange stripes for its logs, a pink roof, a purple door and chimney and a teal window.
The palette. The colour system has five colour families (yellow, orange, pastel pink, purple and teal), each with a light, main and dark tone, plus cream and a deep navy. These shared tones appear in the ghost, the apples, the cabin, the HUD panels, the lives indicator and the planets, tying the character, interface and world together.
The sky. The celestial artwork was carried over and rescaled from an earlier iteration of my game project. It includes a sun wrapped in eight rings of arcs spinning at different speeds and opacities, a cratered moon, ringed planets in the palette colours, a striped planet with cloud streaks drifting across it and a pale black hole with teal and pink rings turning around a glowing core. Because the whole layer rotates with the timer, the sky works as a second clock: you can tell how much time is left just by looking up.
Ghost Run started from a broader concept I called ColourBound, which revolved around restoring colours to a world. As the term progressed and the actual assignment requirements became clearer, the project evolved into a much simpler timed platformer. The finished game does not include the colour-restoration idea. Ghost Run is its own, smaller game, built to meet the brief.
The game grew alongside the course's weekly exercises. Each week introduced a new concept, and I would fold it into the project. I tried to keep iterating throughout, experimenting with new ways to build in my ideas and creating new visual assets along the way.
The most difficult part was building on top of my own earlier iterations. My experiments often produced fairly bloated code, and later weeks would show me a much cleaner or simpler way to do the same thing. That meant repeatedly going back to restructure and clean up functions so they would work with the next exercise. Some of that history is still visible in the source, where comments note things like drawing code carried over from an earlier version or a realisation that there were "much easier ways to do this."
My main formal extension was adding sound with the p5.sound library, which we explored in week 18. I wanted the game to feel more alive, so I added background music, jump, win and game-over sounds, and a randomised set of 13 different death sounds. The death sounds became one of my favourite parts of the game.
Alongside that, I kept expanding the game with my own ideas: the timed day-to-night sky cycle, the game-over and victory overlays, the time-based scoring system and the randomised collectable order with its objectives display.
The easiest way to play is the hosted version:
https://oceanpro00.github.io/ghost_run/
Use a desktop or laptop browser with a keyboard. Press any key to start the music.
The game loads its sound files with loadSound(), and most browsers block that kind of file loading when a page is opened directly from your file system (a file:// address). Double-clicking index.html may give you a page that never finishes loading. The reliable approach is to serve the folder with a small local web server.
1. Get the code
git clone https://github.com/Oceanpro00/ghost_run.git
cd ghost_runOr download the repository as a ZIP from GitHub and unzip it.
2. Start a local server from the project folder using any one of these options:
# Python 3
python3 -m http.server 8000# Node.js
npx serve .You can also use the Live Server extension in Visual Studio Code: open the folder, right-click index.html and choose Open with Live Server.
3. Open the game at the address your server prints, for example http://localhost:8000 for the Python command.
| Requirement | Details |
|---|---|
| Libraries | lib/p5.min.js and lib/p5.sound.min.js are included in the repository, so no install or internet connection is needed once the files are served. |
| Audio files | All 17 files in assets/ must be present. preload() waits for every sound, so a missing or misnamed file stops the game from starting. |
| Browser audio | Music begins on your first key press. If you hear nothing, press a key and check that the tab isn't muted. |
| Input | A physical keyboard. There are no touch or mouse controls. |
| Screen | The canvas is a fixed 1000 × 576 pixels, so a window at least that size gives the best view. |
Because index.html sits at the root of the repository, GitHub Pages can host the game without any build step:
- Push the repository to GitHub.
- Go to Settings → Pages.
- Under Build and deployment, choose Deploy from a branch, then select your main branch and the
/ (root)folder. - After a minute or two, the game will be available at oceanpro00.github.io/ghost_run.
.
├── assets
│ ├── backgroundMusic.mp3
│ ├── death_0.wav
│ ├── death_1.wav
│ ├── death_10.wav
│ ├── death_11.wav
│ ├── death_12.wav
│ ├── death_2.wav
│ ├── death_3.wav
│ ├── death_4.wav
│ ├── death_5.wav
│ ├── death_6.wav
│ ├── death_7.wav
│ ├── death_8.wav
│ ├── death_9.wav
│ ├── game_over.wav
│ ├── jump.wav
│ └── winner_winner.wav
├── documentation
│ ├── gameplay-day.png
│ ├── gameplay-dusk.png
│ ├── gameplay-night.png
│ ├── gameplay.gif
│ └── victory-screen.png
├── index.html
├── lib
│ ├── p5.min.js
│ └── p5.sound.min.js
├── README.md
└── sketch.js
| Path | Purpose |
|---|---|
index.html |
The page that loads p5.js, p5.sound and the sketch |
sketch.js |
The entire game: world data, drawing functions, character, collisions, timer, scoring, sound and game states |
lib/ |
Local copies of the p5.js and p5.sound libraries |
assets/ |
Background music plus the jump, victory, game-over and 13 death sound effects |
documentation/ |
Screenshots and the gameplay GIF used in this README |
README.md |
This document |
Ghost Run is a first-year project with a deliberately contained scope, and it has some limits worth knowing about:
- Keyboard only. There are no touch or mouse controls, so the game is not playable on phones or tablets.
- Fixed canvas. The canvas is always 1000 × 576 pixels and does not resize to fit the browser window.
- Frame-based timing. The 20-second countdown and all movement are measured in frames. On a device that can't maintain 60 fps, the game runs slower, including the clock.
- No start screen or pause. The countdown begins as soon as the game loads.
- Temporary high scores. Scores are kept in memory and reset when the page is reloaded. Only winning runs are recorded.
- One looping level. There is a single world with fixed canyon and apple positions. The only change between rounds is the apple order.
The project gave me sustained practice with the core building blocks of programming: arrays of objects for canyons and apples, loops to draw and check them, functions to break drawing and logic into reusable pieces, Boolean state to control what the game allows at any moment, randomisation for the objectives and death sounds, and plenty of debugging along the way.
Most of all, it made me more confident about refactoring. Working iteratively, week after week, meant regularly revisiting code I'd written when I knew less, recognising where it had become bloated and simplifying it into cleaner functions. Designing the game around a set of explicit states (playing, falling, won, lost, viewing high scores) also taught me how much easier a program becomes to reason about when each part of the code only acts in the states where it should.
Sean Schallberger University of London · BSc Computer Science Introduction to Programming I · 2026
All in-game visuals (the ghost, landscape, cabin, apples, celestial objects and HUD) are drawn in code in sketch.js.
All music and sound effects were sourced from Freesound.
| File | Used for | Source | Licence |
|---|---|---|---|
assets/backgroundMusic.mp3 |
Background music | Freesound | CC0 1.0 |
assets/jump.wav |
Jump | Freesound | CC0 1.0 |
assets/winner_winner.wav |
Victory | Freesound | CC0 1.0 |
assets/game_over.wav |
Game over | Freesound | CC0 1.0 |
assets/death_0.wav – assets/death_12.wav |
Randomised death sounds | "game announcer sounds" by artwork999 | CC BY 4.0 |
The 13 death sounds are clips cut from artwork999's "game announcer sounds". The original recording has been trimmed into separate files for use in this game.
The full source is available at github.com/Oceanpro00/ghost_run.



