Skip to content

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.

KindProject package: linked into the builds of the projects that list it
Sourcepackages/jengine-racing
Version0.1.0
Entry pointjengine_racing::RacingPackage
Script namespaceRacing
Listed byprojects/arcade-classics, projects/car-classics

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.

Scene keyRust type
racing_raceRacingRacepackage 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
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
  • projects/arcade-classics/assets/scenes/circuit.scene
  • projects/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.

ModuleWhat it is
trackA 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.
carThe handling model: four forces and one grip limit.
aiDrivers that aim at a point ahead on their own line and take each corner as fast as the grip allows.
raceThe field, laps, times, standings and shunts, in fixed 1/120 steps.
replayGhosts recorded as input, replayed by running the handling model again.
courseWhat 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.
duelThe shared camera as a rule, and head-to-head scoring: the eight-light bar, or points.

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.

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.

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.

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) and drag (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 is Car::step_on(spec, drive, grip, engine, drag, dt), which with one surface and no drag is Car::step, to the bit.
  • obstacles: solid; a car is pushed out along the shortest way and keeps bounce of its speed into it (hit events past 120 units/s).
  • ramps: faster than min_speed, travelling within ~65 degrees of the ramp’s heading (absent: the track’s own direction there), and the car leaves the table for speed * lift seconds - no steering, no grip, no engine, a parabola for drawing (height), over pits and obstacles alike.
  • pits and table ([x0, y0, x1, y1], off its edge is a fall): the car falls for fall seconds 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.

CallDoes
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
  • RacingState every frame: phase (countdown, racing, finished), time, countdown, laps, lap_record, and racers - 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) and advice. ghost when one is running; view (x, y, half_w, half_h, followed) and duel (points, round, timer, pause, lights, target, round_winner, champion) when those are on.
  • RacingTrack once: 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 - and hit, jump, land, fall, out, catch_up, round and match on a tabletop.