Arc 1 · Make it fly / Tutorial 03 of 16
Two things here. The equation that governs every rocket ever built — which you're going to derive the useful forms of yourself — and an autostaging routine that doesn't jettison a live stage, fire during a coast, or dump your whole rocket in one tick.
Autostaging means asking the rocket about itself. These are the questions you can ask.
es with every engine on the vessel. Note the syntax — this is a statement, not a function.e is each engine part in turn.STAGE. when not ready throws an error.WHEN cond THEN { … } — that runs in the background. It looks perfect for autostaging and it is a trap for now: triggers fire between your loop's instructions, must complete within one tick, and interact badly with WAIT. Poll from your main loop instead. You'll know when you actually want a trigger, and it won't be here.
You know this one. What you probably haven't done is bend it into the three shapes you actually need in flight software.
m0 = mass at ignition · mf = mass at burnout · g0 = 9.80665 m/s² always, everywhere, even at Duna
1 — Burnout mass. Divide by Ispg0 and exponentiate both sides:
Note the negative exponent — that's the form you want in code, because it's numerically better behaved and reads as "shrink the mass by this factor".
2 — Propellant required.
This is the single most useful rearrangement in the whole game. It answers "can I make this burn with what's in the tank?" without simulating anything.
3 — Numbers. Ispg0 = 320 × 9.80665 = 3138.1 m/s.
Now shave 2 t of dry mass. Propellant was 40 − 14 = 26 t, so the new masses are m0 = 38 t, mf = 12 t:
+323 m/s for 2 tonnes. That's the whole argument for staging in one line: mass you're no longer accelerating is Δv you get to keep. An empty tank is dead weight that the logarithm punishes you for, and dropping it mid-flight is the only way to move the ratio.
Tsiolkovsky tells you nothing about time. It doesn't contain thrust at all — a 5 kN ion engine and a 5 MN booster with the same mass ratio and Isp give identical Δv. Time comes from mass flow.
Two facts to start from. Thrust is momentum thrown per second:
and specific impulse is just exhaust velocity in disguise:
1 — Mass flow. Substitute ve and solve for ṁ:
2 — Time to expend the propellant. Constant flow, so it's just quantity over rate:
3 — In terms of Δv. Substitute mf = m0e−Δv/Ispg0, factor out m0:
Give this one a name — it's how you'll split a maneuver node in half in Tutorial 10, so that you start burning early enough to finish late enough. Every burn you plan from here on runs through it.
4 — Units. kN ÷ (s × m/s²) = kN ÷ (m/s) = (kg·m/s²·10³) ÷ (m/s) = 10³ kg/s = t/s. So ṁ is in tonnes per second, and tonnes ÷ (t/s) gives seconds. The kilo-prefix on newtons and the kilo-prefix on grams cancel exactly. This is why KSP's unit choices are quietly excellent and why you should never introduce a conversion factor in kOS — if you find yourself writing 1000 somewhere, you've made an error.
SHIP:AVAILABLETHRUST live rather than trusting a number computed at ignition.Everything you just derived, wired together. Put a real stage from your VAB into it.
Nearly everyone writes this first. It works on one rocket and then destroys the next one.
WHEN SHIP:AVAILABLETHRUST < 0.1 THEN { STAGE. PRESERVE. }
Four separate ways this bites:
STAGE. then raises a hard error and kills your script mid-ascent, leaving the rocket unsteered. Check STAGE:READY.Write the staging condition as plain boolean logic — four clauses joined by AND. Then decide: what should your cooldown duration be, and what should the function return in the edge case where no engines are ignited at all? Both answers depend on when your loop starts running relative to liftoff.
launch2.ks. Keep the pitch program exactly as it is.shouldStage() returning a boolean, implementing all four guards.autoStage() that calls it, stages when true, and records the time so the cooldown works.autoStage() from your ascent loop, every pass.stageDeltaV() function: current mass, current available thrust, a mass-weighted Isp across active engines, and the propellant remaining in the current stage. Print the result live.SHIP:MASS − SHIP:DRYMASS — that's the whole vessel's propellant, including upper stages you haven't lit. Getting a correct per-stage figure means looking at which resources are actually reachable by the burning engines. Attempt it, get it wrong, and read the solution's discussion — this is a case where the honest answer is that the clean version needs tools you don't have yet.
// launch3.ks — pitch program + autostaging + live stage dV. 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. // ---- staging state ---- SET g0 TO 9.80665. SET stageCooldown TO 1.0. SET lastStageAt TO 0. // ---- launch ---- SAS OFF. RCS OFF. LOCK THROTTLE TO 1. LOCK STEERING TO HEADING(azimuth, pitchAt(SHIP:ALTITUDE)). SET lastStageAt TO TIME:SECONDS. STAGE. PRINT "LIFTOFF " AT (0,0). UNTIL SHIP:APOAPSIS > targetAp { autoStage(). telemetry(). WAIT 0. } LOCK THROTTLE TO 0. PRINT "MECO " AT (0,0). WAIT 1. UNLOCK STEERING. UNLOCK THROTTLE. SAS ON. // =========== pitch program (from tutorial 02) =========== 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 ^ kShape). } // =========== staging =========== FUNCTION shouldStage { // Guard 1: never while coasting. IF THROTTLE <= 0.01 { RETURN FALSE. } // Guard 4: never when the staging system is busy. IF NOT STAGE:READY { RETURN FALSE. } // Guard 2: debounce. IF TIME:SECONDS < lastStageAt + stageCooldown { RETURN FALSE. } // Guard 3: every lit engine must be spent. LOCAL es IS LIST(). LIST ENGINES IN es. LOCAL lit IS 0. LOCAL spent IS 0. FOR e IN es { IF e:IGNITION { SET lit TO lit + 1. IF e:FLAMEOUT { SET spent TO spent + 1. } } } IF lit = 0 { RETURN TRUE. } // nothing lit and we want thrust RETURN spent = lit. } FUNCTION autoStage { IF shouldStage() { SET lastStageAt TO TIME:SECONDS. STAGE. PRINT "STAGE " + STAGE:NUMBER + " " AT (0,0). } } // =========== performance =========== FUNCTION activeIsp { LOCAL es IS LIST(). LIST ENGINES IN es. LOCAL fTot IS 0. LOCAL fOverIsp IS 0. FOR e IN es { IF e:IGNITION AND NOT e:FLAMEOUT AND e:ISP > 0 { SET fTot TO fTot + e:AVAILABLETHRUST. SET fOverIsp TO fOverIsp + e:AVAILABLETHRUST / e:ISP. } } IF fOverIsp <= 0 { RETURN 0. } RETURN fTot / fOverIsp. } // Approximate: treats all remaining onboard propellant as usable. // Honest about being an upper bound. See the notes below. FUNCTION vesselDeltaV { LOCAL isp IS activeIsp(). IF isp <= 0 { RETURN 0. } LOCAL m0 IS SHIP:MASS. LOCAL mf IS SHIP:DRYMASS. IF mf <= 0 { RETURN 0. } RETURN isp * g0 * LN(m0 / mf). } FUNCTION burnTimeFor { DECLARE PARAMETER dv. LOCAL isp IS activeIsp(). LOCAL f IS SHIP:AVAILABLETHRUST. IF isp <= 0 OR f <= 0 { RETURN 0. } LOCAL ve IS isp * g0. RETURN (SHIP:MASS * ve / f) * (1 - CONSTANT:E ^ (-dv / ve)). } 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 "PITCH " + ROUND(pitchAt(SHIP:ALTITUDE),1)+" " AT (0,5). PRINT "STAGE " + STAGE:NUMBER + " " AT (0,6). PRINT "ISP " + ROUND(activeIsp(),1) + " " AT (0,7). PRINT "dV " + ROUND(vesselDeltaV()) + " " AT (0,8). PRINT "TWR " + ROUND(SHIP:AVAILABLETHRUST/(SHIP:MASS*9.81),2)+" " AT (0,9). }
Combining engines of different Isp is not an average of their Isp values. The correct combination weights by mass flow, and since flow is F/(Ispg0), the total flow is ΣFi/Isp,i divided by g0. Setting total thrust over total flow equal to an effective Isp gives:
That's a thrust-weighted harmonic mean, which is exactly what activeIsp() computes. Take a plain arithmetic average of a 320 s vacuum engine and a 170 s solid booster and you'll overestimate your Δv badly — the low-Isp engine is gulping propellant far faster than its thrust share suggests.
vesselDeltaV() uses SHIP:DRYMASS, which is the whole vessel with every tank empty. So during your first stage it counts the propellant sitting in stages two and three as though the first stage's engines could burn it. The number is an upper bound, and it's wrong by more the earlier in the flight you are.
Doing this properly means walking the fuel-crossfeed graph: for each burning engine, find which tanks it can actually draw from, sum only those resources, and account for tanks that will separate. kOS exposes what you'd need — e:CONSUMEDRESOURCES, part-level resource lists, decoupler stage assignments — but assembling it correctly is a genuine project, not a paragraph.
So the right call for now is the one taken above: compute the approximation, and know it's an approximation. Flight software full of quantities whose error you haven't characterised is how missions get lost. A number you understand the bias of is more useful than a number you merely hope is right. When you build the per-stage version in Arc 3, the logged data will tell you exactly how far off this one was.
IF lit = 0 { RETURN TRUE. } reads as reckless — no engines lit, so stage. It's safe here only because guards 1, 2 and 4 have already run: throttle is up, the cooldown has elapsed, and staging is ready. Together those mean "we are asking for thrust, we've waited, and nothing is burning" — which genuinely is the signal to fire the next stage, and it's what catches a separation where the next engine needs an explicit ignition.
Reorder those four checks and this becomes a rocket-shredder. Guard ordering is load-bearing, not stylistic.
Three tutorials in, you have a script that flies a multi-stage rocket to orbit unattended. That's the point where most people stop.
You now know: that LOCK binds an expression and makes control laws live; how to build a function and call it from inside a lock; how to derive an interpolation, clamp it, and differentiate it numerically; the three useful rearrangements of Tsiolkovsky; where burn time comes from; and why an autostage routine needs four independent guards rather than one clever condition.
You cannot yet answer any of these:
vesselDeltaV() is biased high. By how much, at what point in the flight?Every one of those is a question about a number you didn't record. Arc 2 is one tutorial long and it's the hinge of the whole series: you'll write telemetry to CSV, accumulate the loss integrals in-flight, and build the analyser that overlays runs against each other. After that, every claim any tutorial makes — including the ones above about k = 0.5 — becomes something you can check rather than accept.
launch3.ks at least three times with different turn parameters, on the same rocket, and write down apoapsis and remaining fuel each time. In the next tutorial you'll re-fly those same configurations with logging on — and having the crude numbers first makes it much more obvious how much you were missing.