games / launchpad

I built a mobile web-based vertical platformer, Launchpad.

The game has a startup journey metaphor. You pilot a rocket upward, bouncing off platforms. Normal platforms decay and break after two bounces, showing a startup pitfall message when they shatter. Boost platforms are branded with the IVP logo and give extra bounce force. Miss a platform and fall — game over.

Architecture

The architecture is the same as Duck Duck Bay: a Go game engine compiled to WebAssembly, a Go HTTP server for static assets and templates, and vanilla HTML, CSS, and JavaScript on the client.

Physics

All physics constants live in Go:

const (
    Gravity      = 0.3
    BounceForce  = -12.0
    BoostForce   = -18.0
    MoveForce    = 0.8
    MaxVelocityX = 8.0
    Friction     = 0.98
)

Gravity pulls the rocket down each tick. Bouncing off a platform sets vertical velocity to BounceForce (upward). Boost platforms use a stronger BoostForce. Horizontal movement applies MoveForce impulses capped at MaxVelocityX, with Friction applied each frame so the rocket drifts and decelerates.

The rocket bounces off screen edges rather than wrapping:

if g.Rocket.X < 0 {
    g.Rocket.X = 0
    g.Rocket.VX = -g.Rocket.VX
} else if g.Rocket.X > CanvasWidth-g.Rocket.Width {
    g.Rocket.X = CanvasWidth - g.Rocket.Width
    g.Rocket.VX = -g.Rocket.VX
}

Platform decay

Normal platforms break after two bounces. The first bounce is normal. The second bounce still propels the rocket upward, but the platform shatters and a pitfall message appears:

p.BounceCount++
if p.BounceCount >= 2 {
    p.Broken = true
    g.SoundEvents = append(g.SoundEvents, SoundBreak)
    g.PitfallMsg = PitfallMessages[g.rng.IntN(len(PitfallMessages))]
    g.PitfallTimer = 120 // ~2 seconds at 60fps
}

Boost platforms never decay, rewarding the player for landing on them.

Pitfall messages

The pitfall messages are startup and VC-themed:

var PitfallMessages = []string{
    "Surprise AWS bill",
    "Claude did it in one prompt",
    "Competitor raised $100M",
    "Runway down to 3 months",
    "Series A fell through",
    "Roasted on Hacker News",
    "It's always DNS",
    "CAC > LTV",
    // ... 40+ more
}

Platform reachability

Every platform must be reachable from the previous one. The game constrains horizontal distance between consecutive platforms:

const MaxHorizontalGap = 250

prevX := g.Platforms[len(g.Platforms)-1].X
minX := prevX - MaxHorizontalGap
maxX := prevX + MaxHorizontalGap

The max bounce height from the physics is v²/(2g) = 12²/(2×0.3) = 240px, and vertical gaps are 60–100px, so the player always has a reachable path upward.

Background gradient

The background color shifts as altitude increases, transitioning from earth green to sky blue to dark atmosphere to black space:

func calculateBackgroundColor(altitude int) string {
    if altitude < 1000 {
        t := float64(altitude) / 1000
        return lerpColor(0x3d5c3d, 0x87ceeb, t) // Earth → Sky
    } else if altitude < 5000 {
        t := float64(altitude-1000) / 4000
        return lerpColor(0x87ceeb, 0x1a1a4e, t) // Sky → Atmosphere
    } else if altitude < 10000 {
        t := float64(altitude-5000) / 5000
        return lerpColor(0x1a1a4e, 0x0a0a1a, t) // Atmosphere → Space
    }
    return "#0a0a1a"
}

Stars fade in above altitude 4000 using a parallax scroll at 10% of the camera speed.

Synthesized audio

All sound is generated from oscillators and noise via the Web Audio API. No audio files are loaded.

The game emits sound event strings from Go ("bounce", "boost", "break", etc.) and JavaScript maps them to synthesizer calls:

Above altitude 4000, a background drone fades in — two slightly detuned sine waves (55 Hz and 55.5 Hz) creating a slow beating effect. The drone volume and pitch shift upward in deep space:

const droneVolume = Math.min(0.06, ((altitude - 4000) / 6000) * 0.06);

Fixed timestep

The game loop uses a fixed timestep to ensure consistent physics regardless of the display's refresh rate:

const FIXED_DT = 1000 / 60; // 60 ticks per second

function gameLoop(currentTime) {
    const deltaTime = Math.min(currentTime - lastTime, 100);
    lastTime = currentTime;
    accumulator += deltaTime;

    while (accumulator >= FIXED_DT) {
        tick();
        accumulator -= FIXED_DT;
    }

    const state = JSON.parse(getState());
    render(state);
    requestAnimationFrame(gameLoop);
}

On a 120 Hz display, two render frames share one physics tick. On a 60 Hz display, each render frame runs one tick. The deltaTime cap at 100ms prevents a spiral of death if the browser tab loses focus and accumulates a large delta.

Play

Play the game at launchpad.ivp.com.

← All articles