Post

QuickSki Update: Forgiving Flags, Speed Ramp, and AI Skiers

QuickSki Update: Forgiving Flags, Speed Ramp, and AI Skiers

QuickSki started life as a Haxe/OpenFL slalom game – ski downhill, thread gates, don’t hit trees. After porting TappyPlane to Godot 4, I gave QuickSki the same treatment. But this time, it wasn’t just a straight port. Three things needed to change to make the game actually fun.

QuickSki Update

The Problem: Pixel-Perfect Pain

The original game used Nape physics with precise polygon collision shapes. Hit a flag post by a single pixel? Dead. Clip the corner of a banner while your ski tips have already cleared it? Dead. It was technically correct – and it felt terrible. Real skiing doesn’t work that way. Your ski tips can brush past a gate; only a body hit matters.

Fix 1: Forgiving Flag Collision

The key insight is simple: if the bulk of the skier’s body has already cleared past the bottom edge of a flag banner, forgive the collision.

1
2
3
4
5
6
7
func _on_ski_guy_hit_obstacle(area: Area2D) -> void:
    var parent := area.get_parent()
    if parent != null and "successfully_passed" in parent:
        # Ski tips past the flag bottom? Forgive it.
        if _ski_guy.position.y + 10.0 > parent.position.y + 16.0:
            return
    _crash_death()

The +10 offset represents roughly the midpoint of the skier sprite (the body has cleared). The +16 is the bottom edge of the flag banner. If you’re past it – you’re through. Trees and other obstacles get no forgiveness. Hit a tree, you crash. Every time.

This single change makes the game feel dramatically better. You can thread tight gates aggressively without dying to phantom clips.

Fix 2: Progressive Speed

The original had one speed: fast. From the first gate to the last, same gravity, same max velocity. It made the opening feel frantic and the late game feel flat.

Now speed starts gentle and ramps up as you pass gates:

1
2
3
4
5
6
7
8
const START_SPEED_Y := 150.0   # was fixed at 175
const START_SPEED_X := 300.0   # was fixed at 360
const START_GRAV := 40.0       # was fixed at 50

func increase_speed() -> void:
    grav = minf(grav + 1.0, 70.0)
    max_speed_y = minf(max_speed_y + 2.5, 250.0)
    max_speed_x = minf(max_speed_x + 5.0, 450.0)

Each gate passed bumps gravity, vertical speed, and lateral speed. The caps keep things challenging but playable – by gate 40 you’re flying, but the controls still feel responsive. The early gates now serve as a warm-up instead of an instant wall.

Fix 3: AI Skiers

After 12 gates, other skiers start appearing on the slope. They spawn above the viewport and ski down with random speed and direction, changing course every 1-3 seconds. They’re on the obstacle collision layer, so hitting one is the same as hitting a tree – instant crash.

1
2
3
4
5
6
7
8
9
func _spawn_ai_skier() -> void:
    var ai := Area2D.new()
    ai.set_script(_AiSkierScript)
    ai.position = Vector2(
        randf_range(60.0, 420.0),
        _camera.offset.y - 50.0
    )
    add_child(ai)
    _ai_skiers.append(ai)

They use the same ski sprites as the player (straight/left/right) and weave around unpredictably. It adds a dynamic obstacle layer that the fixed gates can’t provide – you can memorize a gate pattern, but you can’t predict where the AI will swerve next.

The spawning ramps up gradually: one every 3-5 seconds once you’re past gate 12. They get culled when they scroll off the bottom of the screen, and they all get cleaned up on restart.

The Godot Conversion Notes

A few things I ran into during this round:

Reserved property names: Godot 4’s Area2D has a built-in gravity property. Naming a variable gravity in a script extending Area2D causes a silent compile error – the export just fails with “configuration errors” and no useful message. Renaming to grav fixed it. Classic.

Manual physics over _physics_process: The AI skiers use an update_ai(delta) method called from the game manager instead of their own _physics_process. This keeps movement tied to game state – when the game is paused or in game-over, the AI freezes too. No need for a separate pause mechanism.

Collision forgiveness math: The forgiveness check uses world positions directly. The gate’s position.y is its center, and +16 reaches the bottom of the 34px-tall banner. The skier’s position.y + 10 is roughly their midsection. This means the forgiveness kicks in when the skier’s torso has cleared the flag – not when their ski tips touch, and not when they’re fully past. It feels right.

Working with Claude: Iteration Over Perfection

This port was built across three Claude Code sessions with Opus 4.6 — not as a straight conversion, but as a redesign. The original worked fine as a jam game. The goal this time was to make it actually fun.

By the Numbers

Metric Value
Human prompts 23 across 3 sessions
AI responses 202
Tool calls 253 (71 file reads, 49 edits, 47 shell commands)
Output tokens generated 73,945
Cache-served input tokens 15.0M
Total tokens processed ~15.5M
Active processing time ~29 minutes

What Worked Well

Describing feel, not implementation. I didn’t say “add a y-offset check to skip collision when the skier sprite’s midpoint is past the flag bottom.” I said “real skiing doesn’t work that way — your ski tips can brush past a gate; only a body hit matters.” Claude translated the feel into the right math. The +10 / +16 offset values came from sprite analysis, not from me specifying pixel offsets.

Incremental gameplay tuning. The speed ramp constants (START_SPEED_Y, grav + 1.0 per gate, caps at 70.0 / 250.0 / 450.0) went through three rounds of adjustment. Each time I’d play, describe how it felt (“early game is still too fast” or “late game plateaus too early”), and Claude would adjust the curves. This felt like pair-programming with someone who can instantly calculate the implications of changing a gravity constant.

AI skiers as a late addition. The AI skier system wasn’t in the original plan. I asked for it mid-session after the core felt solid. Claude designed the spawn system, random movement, collision layer integration, and cleanup in one pass. Adding a major feature mid-stream worked because the codebase was small enough (~400 lines at that point) to hold entirely in context.

What I’d Do Differently

Define the fun upfront. The three fixes in this post — forgiving collision, speed ramp, AI skiers — are all game design decisions, not technical ones. If I’d started with “the original is technically correct but not fun; here’s what fun means for a ski game,” we’d have gotten here faster. The AI is great at implementing your vision but it won’t argue with your first draft if you don’t ask it to.

Test on mobile earlier. The touch controls (tap left/right half of screen) were added late. Testing on a phone mid-development would have caught the need for larger touch zones sooner.

Play It

Give it a try right here. Arrow keys or A/D to steer, tap left/right half of screen on mobile:

Open Fullscreen ↗

The source is on GitHub alongside the original Haxe source for comparison.

This post is licensed under CC BY 4.0 by the author.