jengine-rig
A vehicle as a chassis and wheels joined by motorised revolute joints, on terrain built from thick convex quads.
| Kind | Project package: linked into the builds of the projects that list it |
| Source | packages/jengine-rig |
| Version | 0.1.0 |
| Entry point | jengine_rig::RigPackage |
| Script namespace | Rig |
| Listed by | projects/arcade-classics, projects/car-classics |
Using it
Section titled “Using it”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.
Components
Section titled “Components”| Scene key | Rust type | |
|---|---|---|
rig_course | RigCourse | package only: needs a v2 scene |
Script commands
Section titled “Script commands”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.
| Call | Flat 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 |
Scenes that use it
Section titled “Scenes that use it”projects/arcade-classics/assets/scenes/ridge.sceneprojects/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.
| Module | What it is |
|---|---|
terrain | The course as a function of x, and the solid it becomes. Pure. |
run | Distance, fuel, air, flips, and the two ways a run ends. Pure, and knows nothing about rapier. |
content | Vehicles 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.
The course is a function, not a drawing
Section titled “The course is a function, not a drawing”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.
How it drives the physics
Section titled “How it drives the physics”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.
| What | How |
|---|---|
| The drive | A 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. |
| Pitch | One Physics.set_angular_velocity, published the same way. |
| Ground contact | Arithmetic. The package owns the terrain function, so “is this wheel on the ground” is its transform against Profile::solid_at. |
| The camera | The 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.
Pitch damping cannot live on the body
Section titled “Pitch damping cannot live on the body”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
leanhas 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
climbin 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.
What Ridge Runner grew, and what it found
Section titled “What Ridge Runner grew, and what it found”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.
| Field | Does |
|---|---|
RigDef.suspension, suspension_damping, travel | wheels on Wheel joints (a spring along the chassis’s up) instead of rigid Revolute ones |
RigDef.front_drive | a motor on the front wheel too, its share of the torque (four-wheel drive, a snow cat’s tracks) |
RigDef.head | the 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.balance | a 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_delay | the air control does nothing until the vehicle has been airborne this long, so a bump is not a flight |
RigDef.tune + rig_tune | four 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_mass | a 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_scale | ice; the moon (the vehicle’s gravity scale); the share of climb a surface allows, measured, and checked |
CourseDef.grow | waves taller toward the end - harder the further you get, out of the same gradient budget |
CourseDef.ceiling, ceiling_wave | a cave: a roof of quads, and the head checked against it; refused if lower than twice the tallest vehicle |
CourseDef.stream | built a 32-quad chunk at a time, stream ahead and half behind, nearest first, one chunk a frame |
CourseDef.skin | the 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_max | fuel 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, props | distance signs, the best-distance flag (rig_flag), the finish, scenery (unlit for a glowing crystal) |
RigCourse.script_pitch | the 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.
Component ("version": 2 scenes)
Section titled “Component ("version": 2 scenes)”"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.
Script commands
Section titled “Script commands”| Call | Does |
|---|---|
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) |
Published state
Section titled “Published state”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.)