Skip to content

jengine-rig

A vehicle as a chassis and wheels joined by motorised revolute joints, on terrain built from thick convex quads.

KindProject package: linked into the builds of the projects that list it
Sourcepackages/jengine-rig
Version0.1.0
Entry pointjengine_rig::RigPackage
Script namespaceRig
Listed byprojects/arcade-classics, projects/car-classics

List it in the top-level "packages" array of project.jengine:

{
"packages": [
{ "name": "jengine-rig", "source": { "type": "path", "path": "../../packages/jengine-rig" } }
]
}

Then rebuild the editor viewport for the project (jengine viewport-build projects/<name>, or the editor’s Rebuild viewport banner). Native and web builds link it on their own.

A scene that uses its components must be a v2 scene ("version": 2): a v1 scene has no field for a package component and keeps it as inert custom data.

A script can check that the package is linked with if Rig == nil then return end.

Scene keyRust type
rig_courseRigCoursepackage only: needs a v2 scene

Commands run in every runtime and return nothing; a package publishes what scripts read onto components (see the notes below). The flat name also works.

CallFlat name
Rig.start(e)rig_start
Rig.input(e, throttle, lean)rig_input
Rig.tune(e, engine, suspension, tyres, extra)rig_tune
Rig.flag(e, x)rig_flag
Rig.select(e, rig, course)rig_select
  • projects/arcade-classics/assets/scenes/ridge.scene
  • projects/car-classics/assets/scenes/climb.scene

From packages/jengine-rig/README.md.

A physics vehicle on terrain: a chassis and two wheels on motorised revolute joints, over ground the package cuts from a function.

Used by projects/arcade-classics (Tin Ridge) and projects/car-classics (Ridge Runner, a Hill Climb Racing: a garage of six vehicles with four upgrades each, six stages of 2.5 km built as they are driven, coins, fuel cans, a driver whose head ends the run, ice, a cave roof and the moon). Everything Ridge Runner added is a data field that is off by default, so Tin Ridge builds, drives and is tested exactly as it was.

ModuleWhat it is
terrainThe course as a function of x, and the solid it becomes. Pure.
runDistance, fuel, air, flips, and the two ways a run ends. Pure, and knows nothing about rapier.
contentVehicles and courses as data tables, with the checks that make a table refuse to be unplayable.

Two engine bugs, both because nothing had done this before

Section titled “Two engine bugs, both because nothing had done this before”

Every physics game in the arcade before this one used physics for things in a room: crates that topple, a ball that bounces, a character walking a floor. None of them jointed two bodies together and drove the joint, and none of them needed the ground to be long. Both turned out to be broken.

A revolute joint could only pin through the target body’s origin. JointType::Revolute carried a single anchor, handed to rapier as local_anchor1, with local_anchor2 hardcoded to [0, 0] — while Fixed, Rope and Spring all carried both ends. That is invisible for the only revolute joint the repo had, a test pendulum hanging from a peg where the far end really is the origin. It makes a vehicle impossible: two wheels on one chassis both pin to the middle of it. Anchoring from the other side does not help either, because that wants a Joint2D per wheel on one entity and hecs holds one component of a type. JointType::Revolute::anchor_b exists because of this game. BUGS #145, test-lab scene 91.

Thin ground does not stop a fast body. ColliderShape::HeightField is exactly the shape a long rolling course wants and would be one collider instead of hundreds. It is not used, and the reason is measured rather than assumed: a heightfield is a surface with no thickness, and a body crossing it in one step goes through and keeps going — continuous collision or not. crates/jengine-core/tests/terrain_colliders_hold_a_ vehicle.rs holds both halves of that, including the mitigation. A vehicle landing from a jump is exactly that body, so the course is built from convex quads with real depth.

A course is a seed, three (wavelength, amplitude) pairs, a length, a drift and a sample step. Profile turns that into a surface, and quads() cuts it into one convex collider per segment, sharing edges exactly so there is no lip for a wheel to catch on.

That is not a saving in file size. It is what lets a course be checked:

  • no gradient steeper than the vehicle can climb,
  • flat ground under the start, so the vehicle settles before anything is asked of it,
  • a sample step that actually describes a shape,
  • ground cut deep enough to stop something landing on it.

The first of those is the one that earns its place. A hill a vehicle cannot get up is a stall with nothing on screen to say why: the player concludes the game is broken, and they are nearly right. A hand-drawn course can do that silently; a course that is a function cannot, because Profile::steepest can be compared to RigDef::climb at load and the table refused.

The gradient is a budget, and curvature is what makes a jump

Section titled “The gradient is a budget, and curvature is what makes a jump”

The three waves do different jobs. The long one is the shape of the land and is what throws the vehicle into the air — a crest launches you when its curvature beats gravity (amp * (TAU / wavelength)² > g / v²), so a gentle roll over sixteen metres does it at speed and a steep-looking short hill does not. The middle one is hills in the sense of things you climb. The short one is texture.

All three spend the same budget: the steepest gradient anywhere has to stay under climb. That is why the courses look flat written down and do not play flat.

The ease-in integrates, it does not multiply

Section titled “The ease-in integrates, it does not multiply”

Worth knowing before editing Profile::height. The waves are eased in from the flat start by multiplying, which is fine. The drift is eased in by integrating that ramp, because multiplying gives a slope of g·r + g·x·r' — three times g at the end of the blend window. A course asking for a gentle constant climb used to put a wall at the top of its own run-up, and that wall was the first thing the vehicle ever met. It presented as a vehicle that could climb a third of what it should.

Not by reaching into it. No runtime shares PhysicsWorld as a package resource — native and the editor viewport each drive rapier on their own hardcoded path — so a package cannot step it, query it or push anything into it. What a package can do is build bodies and read transforms, because those are ECS components, and that turns out to be nearly enough.

WhatHow
The driveA motor built into the wheel’s Joint2D at spawn. The package publishes the speed it wants when it changes; the game’s script passes it to Physics.set_motor_speed.
PitchOne Physics.set_angular_velocity, published the same way.
Ground contactArithmetic. The package owns the terrain function, so “is this wheel on the ground” is its transform against Profile::solid_at.
The cameraThe package publishes where the vehicle is; the script moves the camera.

jengine-pinball and jengine-launcher use the same bridge. The difference here is the third row: a contact query would have been the obvious way to find the ground, and it would have needed a physics world this package cannot reach and been a frame out of date. Owning the terrain as a function removes the question.

solid_at returns the height of the chord between two samples — the top edge of the quad that is actually there — rather than the curve. Using the curve instead leaves every wheel a fraction short between samples, and a vehicle that is intermittently airborne on flat ground has no throttle and no reason a player could see.

The drive is a motor on the rear wheel, and its torque reaction goes into the chassis — so a vehicle with enough torque to climb has enough torque to stand itself up and fall over backwards. Damping fixes that, and rapier’s angular_damping is the obvious place to put it.

It cannot go there. The value that keeps the vehicle upright is about 8, and as a body-level damping that halves the chassis’s spin every ninth of a second, which makes a flip impossible — and a flip is the only thing there is to do with a jump. So RigidBody2D.angular_damping is left at almost nothing and the package damps pitch itself, only while a wheel is touching. The air is left free.

leaning_in_the_air_can_turn_a_jump_into_a_flip is the test that notices if this is ever moved back onto the body.

The numbers are centimetres, and they were measured

Section titled “The numbers are centimetres, and they were measured”

PhysicsWorld::new does not scale positions. length_unit only tunes rapier’s solver and multiplies gravity, so a project with length_unit = 100 runs at 981 units/s² and one world unit is about a centimetre. A bike is 180 units long, weighs 200, and its motor torque is in the millions.

The first draft of this table was a 76-unit vehicle with a mass of 1 and a torque of 900 — a toy the size of a shoe weighing a kilogram — and it could not climb anything.

Which is why tests/drive.rs and tests/ridge.rs build the vehicle into a real PhysicsWorld through ensure_physics_bodies and drive it. RigDef::climb is a claim about physics written in a data row, and every course is refused if it is steeper, so it is the most load-bearing number in the game and the one nothing would tell you had moved. It is driven rather than chosen. Between them those two files caught:

  • the motor sign — a rapier motor drives the second body relative to the first, so positive is forwards, and the answer reasoning gives you drives the vehicle backwards;
  • the scale, above;
  • that pitch damping cannot live on the body;
  • that lean has to reach about 14 rad/s inside a jump to flip at all, which 9 and 35 do not and 120 overshoots;
  • that the hauler climbed less than the bike as first written, because its extra torque is extra torque reaction;
  • every climb in the table being wrong, because they had been measured before the vehicles were rescaled and nothing re-measured them.

Each of those would have been an afternoon of driving to find. The #[ignore]d measure_* and what_* tests are how the numbers are re-derived after any change to mass, torque, geometry or grip.

Every one of these is a field on a row (RigDef, CourseDef) or on the component, defaulting to off; tests/runner.rs loads Ridge Runner’s real tables and drives them in a real PhysicsWorld, as ridge.rs does Tin Ridge’s.

FieldDoes
RigDef.suspension, suspension_damping, travelwheels on Wheel joints (a spring along the chassis’s up) instead of rigid Revolute ones
RigDef.front_drivea motor on the front wheel too, its share of the torque (four-wheel drive, a snow cat’s tracks)
RigDef.headthe driver’s head, [x, y, r] in the chassis’s frame: touching the ground or a roof ends the run on that frame (ended: "neck")
RigDef.balancea rider leaning the chassis back toward the slope while a wheel is down - a bike that does not stand up on its back wheel
RigDef.lean_delaythe air control does nothing until the vehicle has been airborne this long, so a bump is not a flight
RigDef.tune + rig_tunefour upgrades (engine, suspension, tyres, and a fuel tank or 4WD), as fractions of the row per level; RigDef::tuned
RigDef.art_size / art_offset / wheel_color / wheel_massa picture bigger than the collider (a driver above the body), untinted wheels
CourseDef.rig: "*"the course is checked against every vehicle, not one - any of them may be taken down it
CourseDef.friction, gravity, climb_scaleice; the moon (the vehicle’s gravity scale); the share of climb a surface allows, measured, and checked
CourseDef.growwaves taller toward the end - harder the further you get, out of the same gradient budget
CourseDef.ceiling, ceiling_wavea cave: a roof of quads, and the head checked against it; refused if lower than twice the tallest vehicle
CourseDef.streambuilt a 32-quad chunk at a time, stream ahead and half behind, nearest first, one chunk a frame
CourseDef.skinthe driving surface as a polyline a chunk, the quads sunk skin under it (see below)
CourseDef.art: "spline"each chunk drawn as a spline_shape (a tiled fill and an edge strip), the ceiling too
CourseDef.can_every / can_growth / can_maxfuel cans spaced further apart each time (coast_out: an empty tank rolls on until it stops)
CourseDef.coin_*runs of coins over the ground, worth more by distance (coin_tiers)
CourseDef.marker_*, flag_art, finish_art, propsdistance signs, the best-distance flag (rig_flag), the finish, scenery (unlit for a glowing crystal)
RigCourse.script_pitchthe pitch law published for the script to apply a physics step at a time (see below)

Three things only driving it found:

A package’s view of the chassis’s spin is wrong on a real display. The pitch damping and the air control were one set_angular_velocity a frame, worked out from how the transform moved since last frame. Natively a package’s ScriptExec runs before the physics step, so it reads the pose blended for drawing; and on a 165 Hz display most frames have no step at all. After a bump the “spin” it read was hundreds of radians a second, the damper set the chassis to it, and the jeep went onto its roof on the first hill - while every headless test, a step per frame, drove perfectly. With script_pitch the package publishes RigState.pitch = [damping, push, cap] and the game applies omega * (1 - damping * dt) + push * dt in fixed_update, with the body’s real spin, before every step (climb/main.lua). The per-frame path now measures time since the chassis last moved (since_step), which is the frame’s dt whenever a frame is a step - Tin Ridge’s tests are unchanged. The engine half is fixed too (Oct 2026): every host now puts the stepped pose back at the start of its frame (PhysicsWorld::restore_stepped_poses), so a package’s ScriptExec reads the last step, not the blend - script_pitch stays, because a rate applied once a frame is still not a rate applied once a step.

Side-by-side convex quads are a washboard. A wheel rolling over the joint between two quads touches the next one’s vertical face as well as its top: a bump every sample. Rigid wheels shrug it off; on springs at full speed it pumped a vehicle off flat ground within seconds. skin lays the surface as one polyline a chunk (no faces to catch) with the quads sunk under it, still there to stop a landing that goes through a surface with no thickness. Measured in measure_full_gas_on_the_flat.

More torque can climb less. The engine upgrade first added torque only, and on a 0.45 ramp six levels were slower than none: the torque reaction lifted the nose and the driver lifted off. The engine now adds mostly motor speed (tune.speed) and a little torque, and the_engine_upgrade_makes_it_faster holds it to both a sprint and a hill.

"rig_course": {
"tables": { "rigs": "data/rig_rigs", "courses": "data/rig_courses" },
"rig": "trail", "course": "ridge", "sheet": "atlases/ridge.png"
}

Leave rig empty to use whichever vehicle the course names.

CallDoes
Rig.start(e)build the course and begin a run
Rig.input(e, throttle, lean)−1..1 each
Rig.select(e, rig, course)pick a row from each table and restart
Rig.tune(e, engine, suspension, tyres, extra)upgrade levels for the next start
Rig.flag(e, x)where the best-distance flag stands on a streamed course (0: none)

RigState carries the phase, the course and vehicle names, distance and best distance, progress, fuel (absolute and as a fraction), tricks, flips, air, best air, time, score, and the chassis’s x / y / angle - and since Ridge Runner coins / coin_value, speed, ended (neck, roll, fuel, finish), head, next_can, slope (the ground’s angle ahead, for a pilot), ceiling, gravity, flag_x, levels, pitch and chassis. RigEvents carries start, motor, spin, landed, flip, fuel, stalled, wrecked and finished, and coin, neck, marker and best.

The motor and spin events are not notifications — they are the two things the game script has to act on, because a package cannot reach the physics world. Ignore them and the vehicle does not move. (With script_pitch there are no spin events: apply pitch in fixed_update.)