jengine-racing
Top-down racing: a track spline that answers where you are and how sharp the next bend is, a car that slides, drivers that follow the line, and ghosts replayed from recorded input.
| Kind | Project package: linked into the builds of the projects that list it |
| Source | packages/jengine-racing |
| Version | 0.1.0 |
| Entry point | jengine_racing::RacingPackage |
| Script namespace | Racing |
| 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-racing", "source": { "type": "path", "path": "../../packages/jengine-racing" } } ]}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 Racing == nil then return end.
Components
Section titled “Components”| Scene key | Rust type | |
|---|---|---|
racing_race | RacingRace | 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 |
|---|---|
Racing.new_race(e) | racing_new_race |
Racing.drive(e, throttle, brake, steer, handbrake) | racing_drive |
Racing.drive_seat(e, seat, throttle, brake, steer, handbrake) | racing_drive_seat |
Racing.setup(e, setup) | racing_setup |
Racing.respawn(e) | racing_respawn |
Racing.speed(e, x) | racing_speed |
Racing.auto(e, on) | racing_auto |
Racing.solo(e, on) | racing_solo |
Racing.ghosts(e, on) | racing_ghosts |
Scenes that use it
Section titled “Scenes that use it”projects/arcade-classics/assets/scenes/circuit.sceneprojects/car-classics/assets/scenes/micro.scene
From packages/jengine-racing/README.md.
Top-down racing: a track that answers questions, a car that slides, and ghosts replayed from recorded input.
Used by projects/arcade-classics (Slip Circuit) and projects/car-classics
(Tabletop Turbo, a Micro Machines - and, read-only, jengine-rally). Tabletop
Turbo’s online room (projects/car-classics/server/games/micro) holds a
RacingRace on the server and steps it with Race::step, each person’s
Drive set with Race::set_drive - the same race as at home.
| Module | What it is |
|---|---|
track | A closed centreline smoothed into a spline and resampled by arclength. Answers the three questions everything else asks: how far round the lap, how far off the middle, how sharp the next bend. |
car | The handling model: four forces and one grip limit. |
ai | Drivers that aim at a point ahead on their own line and take each corner as fast as the grip allows. |
race | The field, laps, times, standings and shunts, in fixed 1/120 steps. |
replay | Ghosts recorded as input, replayed by running the handling model again. |
course | What is on the table besides the road: patches that take grip or add drag, obstacles to bounce off, ramps that throw a car into the air, pits to fall into, the table’s edge. |
duel | The shared camera as a rule, and head-to-head scoring: the eight-light bar, or points. |
The grip limit is the game
Section titled “The grip limit is the game”The car has an engine, brakes, drag, and steering that turns the nose,
not the velocity. Tyre friction then pulls the velocity back towards the
nose - but only so hard, grip units of sideways speed per second:
let take = (limit * dt).min(lateral.abs());lateral -= take * lateral.signum();Corner gently and the tyres keep up, so the car goes where it points.
Corner flat out, or pull the handbrake (which cuts grip to a fifth), or
put two wheels on the grass (which scales it), and they cannot - the slip
angle opens up and the car slides.
Getting that limit wrong makes the whole model inert, which is worth
stating because the first draft did: at a grip of 1,500 against the ~1,400
of lateral acceleration a full-lock corner at top speed asks for, nothing
could ever break traction. At 900 the same corner is a 14-degree drift and a
cruise is dead stuck. tests/handling.rs pins both ends down.
Laps without checkpoints
Section titled “Laps without checkpoints”Progress is a monotonic distance along the track, accumulated a step at a time from how far round the lap the car moved. A lap is credited when that distance crosses a multiple of the track length, and nothing else. Three things fall out of it:
- reversing over the line takes the distance back down, so it cannot farm laps;
- cutting across the infield covers less track distance than the road does, so a shortcut is its own punishment - no trigger boxes, no penalties to author;
- a car spun round on the line does not flicker between laps.
An earlier draft used trigger areas as gates and had all three bugs. The checkpoints that remain are for the readout (“gate 3 of 8”) and come from the same number.
Ghosts are input, not positions
Section titled “Ghosts are input, not positions”A recording is the four numbers of Drive per fixed step, plus the car’s
state at the start of the lap. Replaying means running the handling model
again:
- it is a determinism check that runs every lap - if the ghost drifts off the line you drove, the physics is not reproducible, and a racing game whose physics is not reproducible cannot have leaderboards;
- a lap is a few hundred bytes;
- the ghost is a real car, so it can be handed to an opponent or asked how fast it was going anywhere on the lap.
tests/racing.rs::a_ghost_replays_the_lap_it_recorded_exactly records a lap
through the player’s own input path and replays it: the worst divergence
over the whole lap is under a hundredth of a unit. What a ghost cannot
reproduce is contact - it has nobody to hit - so a lap driven through a
shunt replays a little clean.
This is also why the countdown holds the cars rather than braking them. The same pedal brakes and reverses, so braking a stationary car trickles it backwards off the grid, and the first recorded step then started from a state the ghost could not reach.
The component
Section titled “The component”racing_race on one entity, in a "version": 2 scene. The centreline is
authored in world units and closed for you, so do not repeat the first
point. The package draws nothing: the road’s art is whatever the scene puts
there, and the script draws the cars from what it publishes.
"racing_race": { "tables": { "cars": "data/cars" }, "centre": [[0, 0], [600, 0], [700, 300], [0, 400]], "half_width": 120.0, "checkpoints": 8, "laps": 3, "field": ["scout", "scout", "brute"], "player": 0, "ghosts": true}Cars are rows in the project’s data tables, so a class is data: the whole of
CarSpec (engine, brake, reverse, top_speed, roll, steer_rate,
steer_speed, grip, handbrake_grip, offroad, mass, radius) plus skill and
line for the AI, and color / art / name for the game to draw it
with.
A tabletop: the course, several people, a camera that eliminates
Section titled “A tabletop: the course, several people, a camera that eliminates”Everything below is a field that defaults off, and a test proves “off”
means off: tests/slip_circuit_unchanged.rs hashes ninety seconds of a
Slip Circuit race step by step and pins the number the package produced
before any of this existed (its source copied out of git into a scratch
crate). If a default ever changes somebody else’s race, that test says so.
The course (course on the component, crate::course). Every part is an
Area - a circle, a turned rect or an ellipse in world units - so a scene’s
art and the rules are authored from one list:
patches:grip,engine(shares of the car’s own) anddrag(a fraction of speed per second). Spilt milk takes the grip and leaves the engine; sand takes the engine and adds drag. The car’s step isCar::step_on(spec, drive, grip, engine, drag, dt), which with one surface and no drag isCar::step, to the bit.obstacles: solid; a car is pushed out along the shortest way and keepsbounceof its speed into it (hitevents past 120 units/s).ramps: faster thanmin_speed, travelling within ~65 degrees of the ramp’sheading(absent: the track’s own direction there), and the car leaves the table forspeed * liftseconds - no steering, no grip, no engine, a parabola for drawing (height), over pits and obstacles alike.pitsandtable([x0, y0, x1, y1], off its edge is a fall): the car falls forfallseconds and is put back on the road at the first clear place past where it fell, standing. Behind looks fairer and is a trap: a car that fell short of a jump, put back before the ramp from a standing start, is too slow to clear it and falls again for ever (the first draft did exactly that, 260 times in one race). The fall costs more time than a pit’s few car lengths.
The drivers see the course as Hazards: each obstacle, pit and slippery
patch projected once into track coordinates (a stretch of the lap, a span of
the road’s width). Driver::drive_with picks the free lateral nearest its
own line through what is in its window, aims at the gap’s mouth until it is
lined up (or the straight line to a far target cuts through what it steers
round), and slows for a slippery patch it cannot avoid - a poor driver
neither sees the patch nor slows for it. A ruler laid across the gap between
two desks is just the one free lateral through two pits. Driver::drive is
drive_with with no hazards.
Several people (humans: grid rows in seat order). racing_drive_seat(e, seat, ...) drives humans[seat]; the race ends when every human is home.
skills, colors and names override a class per grid row. advise: s
publishes, for each human, what a driver of skill s would do in their seat
(advice on the racer) - nothing applies it; a test turns it into key
presses, an attract mode into pedals.
The camera as a rule (view, crate::duel). A shared-screen racer has one
camera for everyone, following the leader - and which car it leaves behind
decides the game, so the view is worked out in the fixed step beside the
cars and published (view.x, view.y); a script (or an online client
drawing a server’s race) only puts its camera there. follow: leader or
humans. out: eliminate (head-to-head: off the screen is out of the
round) or catch_up (a race with several people: one the screen leaves
behind is put back just behind the one it follows, and held a moment -
Micro Machines’ own rule).
Head-to-head (duel). The last car on the screen takes the round; a
round still open after round_limit seconds goes to the leader, so a field
of equal bots still ends. Two cars play for the bar of target lights
(half each; a round moves one across); more play to target points. After a
pause, everyone lines up side by side where the winner was
(line_up_abreast, walked back off anything in the way) for a short
countdown. abreast: true starts the first round that way too.
One table, six tracks (racing_setup(e, table)): any authored field
changed from a script, and a new race on it - how one scene holds six
tables. Script numbers arrive as floats, and whole ones are taken as
integers; false for a field that is not a flag puts it back to its default
(a script has no nil to put in a table, and “no duel this time” must be
sayable). A setup changes only the keys it names.
Script commands
Section titled “Script commands”| Call | Does |
|---|---|
Racing.new_race(e) | everyone back on the grid |
Racing.drive(e, throttle, brake, steer, handbrake) | the player’s controls |
Racing.respawn(e) | back on the track, facing the right way |
Racing.speed(e, x) | run at x times speed |
Racing.auto(e, on) | hand the player’s car to a driver (attract mode) |
Racing.solo(e, on) | a time trial: the player’s car alone on the track |
Racing.ghosts(e, on) | record and show the best-lap ghost |
Racing.drive_seat(e, seat, throttle, brake, steer, handbrake) | one of several people’s controls (humans[seat]) |
Racing.setup(e, table) | change any field (track, field, course, rules) and start a new race |
What scripts read
Section titled “What scripts read”RacingStateevery frame:phase(countdown,racing,finished),time,countdown,laps,lap_record, andracers- each with position, heading, speed, slip, lap, place, gate, lap/best/all lap times, and whether it is off-track, sliding, going the wrong way or already home - and, on a course,human,seat,air/height(a jump),falling(0..1 into a pit),out,hold,surface(the patch’s name) andadvice.ghostwhen one is running;view(x,y,half_w,half_h,followed) andduel(points,round,timer,pause,lights,target,round_winner,champion) when those are on.RacingTrackonce: the resampled centreline, the width and the checkpoints, for a script that wants to draw the road rather than author it as a tilemap.RacingEvents.events:light,green,lap,ghost,finish,bump,respawn- andhit,jump,land,fall,out,catch_up,roundandmatchon a tabletop.