Arc 1 · Make it fly / Tutorial 02 of 16
That hardcoded 80° in launch1.ks is a cliff. Replace it with a continuous function of altitude, and your rocket stops fighting itself. You'll derive the interpolation, clamp it, and then find the flaw hiding inside it.
Small list this time. The work on this page is mathematical, not syntactic.
ROUND takes an optional digit count.ELSE IF keyword — nest an IF inside the ELSE block.= is equality here, not assignment. Assignment is SET. Inequality is <>.rvqvdpathstepnodelistlograngeheadinglatlngcharbodystagecore
q and v in particular are the ones you'll instinctively reach for when writing physics.
RUN that.A function is just a named expression with holes in it. Here's the whole syntax:
FUNCTION twrNow { RETURN SHIP:AVAILABLETHRUST / (SHIP:MASS * 9.81). } FUNCTION lerp { DECLARE PARAMETER a. DECLARE PARAMETER b. DECLARE PARAMETER f. RETURN a + (b - a) * f. } PRINT twrNow(). PRINT lerp(90, 0, 0.25). // 67.5
Now the connection back to Tutorial 01. Because LOCK stores an expression and re-evaluates it, a function call inside a lock becomes a live control law — the function runs again on every steering update:
LOCK STEERING TO HEADING(90, pitchAt(SHIP:ALTITUDE)).
One line, locked once, before liftoff. It steers the entire ascent. Everything left to do on this page is deciding what pitchAt should return.
Don't look this up. You have two points and you want the straight line between them — that's all a linear interpolation is.
You want pitch to be 90° (straight up) at some altitude h0, and 0° (flat, at the horizon) at some higher altitude h1. Between them it should slide smoothly.
1 — The fraction. Distance travelled divided by total distance:
At h = h0 the numerator vanishes so f = 0. At h = h1 numerator and denominator are identical so f = 1.
2 — The blend. Start at p0 and add the fraction f of the total change:
You may have written the equivalent p = (1−f)p0 + f p1. Same thing, expanded. The first form is better in code: one multiply, and the "start plus a correction" shape generalises to every interpolation you'll write later.
3 — Combined.
4 — Numbers. At h = 12 000:
So p = 90 + 0.25 × (0 − 90) = 67.5°.
And the slope is constant everywhere: dp/dh = (0 − 90) / 44 000 = −0.002045 °/m, or about −2.05 degrees per kilometre. Hold on to that number — it's the villain of the last section on this page.
Your formula is happily defined outside the range you care about, and it will produce nonsense there.
Below h0, f goes negative and pitch exceeds 90° — your rocket leans backwards. Above h1, f exceeds 1 and pitch goes negative, aiming you at the ground. You need f confined to [0, 1].
kOS gives you MIN(a,b) and MAX(a,b), two arguments each. Write clamp(f, 0, 1) using only those two functions, no IF. Then say which one enforces the floor and which the ceiling — people get this backwards constantly.
MAX(f, 0) enforces the floor: it returns whichever is larger, so anything below 0 comes back as 0. MIN(…, 1) enforces the ceiling: anything above 1 comes back as 1.
The mnemonic that actually sticks: MAX raises, MIN lowers. MAX can only push a number up to the floor; MIN can only push it down to the ceiling.
Order doesn't matter here — MAX(MIN(f,1),0) is identical — as long as your bounds are the right way round. If lo > hi, the two orderings disagree, which is a genuinely nasty bug in generic clamp helpers.
Clamp the fraction, not the pitch. Clamping f works no matter which direction the pitch runs. Clamping p to [0, 90] would silently break the moment you want a profile ending below the horizon — which you will, in Tutorial 09.
FUNCTION pitchAt { DECLARE PARAMETER h. LOCAL f IS (h - turnStart) / (turnEnd - turnStart). SET f TO MIN(MAX(f, 0), 1). RETURN pitch0 + (pitch1 - pitch0) * f. }
Drag the values. The straight line is your linear program; the curve adds one exponent.
A real gravity turn isn't linear. Raising f to a power k before blending lets you pitch aggressively early — while you're slow and drag is cheap — then ease off:
k < 1 pitches over early · k = 1 is linear · k > 1 holds vertical longer
Your pitch program is smooth in altitude. The rocket doesn't experience altitude — it experiences time. Those are not the same thing, and the gap between them is a real problem.
You computed dp/dh ≈ −2.05 °/km, constant across the whole turn. But what the airframe feels is the pitch rate, dp/dt.
1 — Chain rule.
And dh/dt is simply vertical speed — the rate at which you're gaining altitude. So your pitch program's rate is its slope multiplied by how fast you're climbing.
2 — Numbers. Take those climb rates as vertical speed:
A factor of 5×, and in exactly the wrong direction.
Why that's wrong. Down low the air is thick, the rocket is heavy and sluggish, and you want to commit to the turn — but your program is turning at its slowest. Up high the air is nearly gone, the rocket is light and the turn is almost free — and your program is whipping the nose around fastest, right where you'd rather be settling onto a shallow trajectory.
You've built a pitch program that is timid when it should be bold and frantic when it should be calm. It works — it will get you to orbit — but it is fighting physics the entire way, and the wasted energy shows up as steering loss.
Three tutorials from now you'll measure that loss in m/s. Five from now you'll delete this function entirely and replace it with a rocket that simply follows its own velocity vector, where the pitch rate falls out of the physics for free.
launch1.ks. Put the tunable numbers — turn start, turn end, start and end pitch, exponent, target apoapsis, azimuth — in named variables at the very top.pitchAt(h) with the clamped fraction and the exponent k.HEADING(azimuth, pitchAt(SHIP:ALTITUDE)). Nothing after that line may touch the steering lock.SHIP:VERTICALSPEED.pitchAt is clamped, so once you're past turnEnd the true pitch rate is zero — but the chain-rule formula doesn't know that. Decide how to handle it. Printing a rate of −1.8 °/s while the nose is locked flat is a lie, and lying telemetry is worse than none.
// launch2.ks — continuous pitch program. CLEARSCREEN. // ---- tuning ---- SET azimuth TO 90. SET targetAp TO 75000. SET turnStart TO 1000. SET turnEnd TO 45000. SET pitch0 TO 90. SET pitch1 TO 0. SET kShape TO 0.5. // ---- launch ---- FROM {LOCAL t IS 5.} UNTIL t = 0 STEP {SET t TO t - 1.} DO { PRINT "T-MINUS " + t AT (0,0). WAIT 1. } SAS OFF. RCS OFF. LOCK THROTTLE TO 1. LOCK STEERING TO HEADING(azimuth, pitchAt(SHIP:ALTITUDE)). STAGE. PRINT "LIFTOFF " AT (0,0). UNTIL SHIP:APOAPSIS > targetAp { telemetry(). WAIT 0. } LOCK THROTTLE TO 0. PRINT "MECO " AT (0,0). WAIT 1. UNLOCK STEERING. UNLOCK THROTTLE. SAS ON. // ---- pitch program ---- FUNCTION turnFraction { DECLARE PARAMETER h. LOCAL f IS (h - turnStart) / (turnEnd - turnStart). RETURN MIN(MAX(f, 0), 1). } FUNCTION pitchAt { DECLARE PARAMETER h. RETURN pitch0 + (pitch1 - pitch0) * (turnFraction(h) ^ kShape). } // Pitch rate via chain rule: dp/dt = (dp/dh)(dh/dt). // Differentiate numerically — one clean line instead of a derivative // that would have to change every time we change the curve shape. FUNCTION pitchRate { LOCAL h IS SHIP:ALTITUDE. LOCAL dh IS 10. LOCAL slope IS (pitchAt(h + dh) - pitchAt(h - dh)) / (2 * dh). RETURN slope * SHIP:VERTICALSPEED. } FUNCTION telemetry { PRINT "ALT " + ROUND(SHIP:ALTITUDE) + " " AT (0,2). PRINT "AP " + ROUND(SHIP:APOAPSIS) + " " AT (0,3). PRINT "SPD " + ROUND(SHIP:VELOCITY:SURFACE:MAG,1) + " " AT (0,4). PRINT "VSPD " + ROUND(SHIP:VERTICALSPEED,1) + " " AT (0,5). PRINT "PITCH " + ROUND(pitchAt(SHIP:ALTITUDE),1) + " " AT (0,6). PRINT "dP/dt " + ROUND(pitchRate(),2) + " deg/s " AT (0,7). }
You could differentiate p(h) = p0 + (p1−p0)fk by hand and get
That's correct, and it's worth doing on paper. But it goes stale the instant you change the curve shape, and it blows up at f = 0 when k < 1. The central difference — sample the function 10 m either side, divide by 20 — costs two extra function calls and stays right no matter what pitchAt becomes. It also solves the requirement-5 problem for free: inside the clamped region both samples return the same value, the slope comes out as exactly zero, and the readout tells the truth.
That trick generalises. When a quantity is awkward to differentiate but cheap to evaluate, sample it instead.
k = 0.5 almost certainly reached the target apoapsis with more fuel left. It commits to the turn early, which means more of the thrust goes into horizontal velocity — the velocity you actually need for orbit — instead of being spent climbing against gravity and then having to be bent sideways later.
But "almost certainly" and "more fuel left" are not measurements. How much? Where did the saving come from — less gravity loss, or less steering loss? Would k = 0.4 be better still? You cannot answer any of that from a fuel gauge, and that's the wall this arc ends on.