Asynchronous physics

Usage guide for kits and tests that talk to the physics: reads that never wait, fire-and-forget orders, questions that hand out a ticket (ask_move_character, ask_cast_character, ask_raycast, ask_overlap_sphere, ask_overlap_box) polled with poll_moved / poll_hit / poll_found, the JavaScript form, where the physics runs, counters.

Source: module-kits:kits/physics_port/usage/en.mdView Markdown source

Also read offline — CLI: grip-forge docs physics-asyncMCP tool: gripforge_docs { "topic": "physics-async" }

Asking the physics without waiting (orders and tickets) #

How a kit written in Rust talks to the physics of a game: reads that never wait, orders that are fire and forget, and questions that hand out a ticket you poll. The same contract holds wherever the physics runs. Readable offline: gripforge docs physics-async, or the MCP tool gripforge_docs (topic physics-async).

What it is for #

On the web the physics can run on a Worker of its own, with its own loop at its own fixed step, writing its results in memory shared with the game. The game thread then never waits for it and never wakes it: it reads what is published, sends orders, and asks questions whose answer arrives a tick or so later. Natively, the engine's own physics (or a thread of the native player) sits behind the same calls. A kit written once against this contract works in every place, so a ray cast or a character move is never a call that returns the answer in the same line.

If you only configure games, you do not need this guide: the kits of the catalogue already use it. It is for those who write a kit (see gripforge docs kit-third-party) or a test that talks to the physics.

The three kinds of call #

kx.physics() in a Rust kit gives you:

KindCallsBehaviour
Readsbody, has_body, has_character, mass, grounded, character_position, character_grounded, contact_events, contacts, posesnever wait; they read the state the physics last published (a body added and not yet taken reads the pose its add asked; a removed body is gone at once)
Ordersadd_body, add_character, add_static_box, add_dynamic_box, remove, set_body, set_position, apply_impulse, apply_force, apply_torque_impulse, move_kinematic, set_character_position, set_gravity, set_manual_stepping, stepfire and forget; bodies are slots chosen by the caller, names stay on the caller's side; if the ring of orders is full the order waits in the writer's backlog, in order, and the writer never blocks
Questionsask_move_character, ask_cast_character, ask_raycast, ask_overlap_sphere, ask_overlap_boxreturn a ticket; poll(ticket) answers Pending until the physics took the order, then the answer once

The questions without ask_ (move_character, cast_character, raycast, overlap_sphere, overlap_box) no longer exist on a kit's physics: a question is asked, and its answer polled.

Asking and polling #

// Ask: an order with a ticket. Keep what the answer will need.
let ticket = kx.physics().ask_raycast(origin, dir, range, Some(&shooter));
state(kx).shots.push(AskedShot { ticket, shooter });

// Take every answer that has come, oldest first.
while let Some(asked) = state(kx).shots.first().copied() {
    let std::task::Poll::Ready(hit) = kx.physics().poll_hit(asked.ticket) else { break };
    state(kx).shots.remove(0);
    apply_hit(state(kx), &asked, hit);
}
QuestionTyped pollAnswer
ask_move_character(entity, delta)poll_movedOption<(V3, bool)>: the position after the move and whether it is grounded
ask_cast_character(entity, origin, delta)poll_hitOption<RayHit>: the first hit, if any
ask_raycast(origin, dir, max_toi, exclude)poll_hitOption<RayHit>: the first hit, if any
ask_overlap_sphere(center, radius, exclude)poll_foundVec<String>: the entities found
ask_overlap_box(center, size, rotation, exclude)poll_foundVec<String>: the entities found

poll(ticket) is the untyped form. All polls return std::task::Poll.

Rules to follow:

  • Do not wait on a ticket. Keep it (with what the answer needs) and poll again on the next tick. Where the physics runs on the game's thread the answer is there at once; on a Worker it is there a tick or so later. Write the same code for both.
  • Use the state the last answer left: for a character, grounded and the last moved position, not a value you computed before asking.
  • forget(ticket) gives a ticket back unread. An answer nobody reads goes back by itself after 600 ticks.
  • Poll oldest first and stop at the first Pending, as in the example: the answers then apply in the order you asked.

The reference kit for the pattern is character.third_person (the move of the character).

From JavaScript and tests #

The JSON names of physics.port (raycast, overlapSphere, overlapBox) ask a ticket and read it in the same call where the physics answers at once; where it cannot, they say "answers later" instead of waiting. To handle the later case, ask with askRaycast / askMoveCharacter / askOverlapSphere and poll: the op physics with poll answers { pending } or the answer.

In the headless test host, physics.ask.raycast(...) returns a promise that is polled after every frame:

const hit = await physics.ask.raycast([0, 2, 0], [0, -1, 0], 10);

Where the physics runs #

The contract is the same in all three places; the only difference is how soon an answer arrives.

PlaceWhenAnswers
The game's threada page whose memory is not shared between threads; the testswhen the ticket is handed out
A Worker of its ownthe web, where the memory is shared between threadsa tick or so later; the game thread never waits
The engine's physicsGodot, Unity, Unreal through the callbacks registered with gfk_set_physics_callbacks (the physics: "engine" default of those hosts)when the ticket is handed out; the engine's own callbacks answer

The loader decides the first two; a game does not ask for them. On the web the physics module is placed on a Worker when the shared memory is available, and calls.physics.why says why it stays on the page's thread otherwise.

Counters #

host.physicsCounters() answers { thread, steps, loopMs, loops, eventsDropped, asked, answered, inFlight, backlog }: the thread it runs on, the steps made, the time of its loop, the questions asked and answered, those still in flight and the orders waiting in the backlog. asked - answered should stay small; a growing backlog means the physics is behind the game.

Native engines #

An engine host that registers gfk_set_physics_callbacks (see gripforge docs c-api) gives its own physics: the callbacks are the port. The poses, forces and step copy nothing between the library and the engine; the names of a ray hit or an overlap and the contact events are copied out of the engine, and gfk_physics_copied reports how many bytes that was.

When it does not do what you expect #

SymptomCause and fix
A character is one tick behind its move on the webexpected on a Worker; read the state after the poll, never assume the move is done
Pending for a long timethe ticket was forgotten or already read (an answer is given once), or the physics is paused because the game is not running
An old call named raycast is refused by a kitremoved: use ask_raycast and poll_hit (the codec's error names ask_* and poll)
The loop is on the page's threadno shared memory in this page; calls.physics.why gives the reason