Files
Ethanw05 6cbe81e697 Merge PRs #37, #24, #53 and #51: research tools, controller support and performance diagnostics
Preserve integrated audio, camera, authored map starts and trigger exports when resolving overlaps. Bound frame-log buffering to 4,096 rows with nonblocking writes and dropped-row reporting.
2026-10-06 15:59:04 -06:00

18 KiB
Raw Permalink Blame History

Engine observations and controls (API 2)

The SDK exposes engine primitives. Game rules, turn order, trick matching, scoring policies, and win conditions belong in Lua mods. No host API depends on a mod ID or a particular game mode.

Player and scoring observations

sdk.player.read() reads the local skater. sdk.player.skaters() returns a table keyed by peer ID; sdk.player.skater(id) reads one entry. Entries include names, pose, velocity, angular velocity, movement mode, grind, stance, score, and named input intents. sdk.net.players() lists connected peers; their first skater observation may arrive later.

  • trick / trick_seq: latest trick announcement, including midair tricks.
  • landed_trick / landing_seq: latest confirmed, banked on-board scoring sequence. The counter advances only after successful native scoring settlement, not on announcement, bail, offboard completion, or teleport. The label remains available until the next confirmed result.
  • bail_seq: counter incremented once when entering a native wipeout.

Counters are observation cursors, not one-frame pulses. Remember the previous counter, establish a baseline when a peer appears, and handle a lower counter as a reset. Remote snapshots carry the latest state, not an unbounded event history; intermediate events may be coalesced if several occur between received updates. Trick labels are display strings, not a structured trick taxonomy. A mod chooses its own matching policy. sdk.capabilities.skater >= 4 includes these counters.

local previous = {}
return {on_fixed_update = function()
    for id, skater in pairs(sdk.player.skaters()) do
        local before = previous[id]
        if before and skater.landing_seq > before then
            sdk.log(skater.name .. " landed " .. skater.landed_trick)
        end
        previous[id] = skater.landing_seq
    end
end}

Native player movement and session authority

sdk.player.teleport({position={x,y,z}, heading=radians, velocity={x,y,z}}) uses native player travel. Heading defaults to zero; velocity is optional. This only moves the local skater. Coordinates are bounded to +/-100000 and velocity components to +/-200. Invalid commands fail validation.

sdk.session.info() reports the transport host and current session authority. Authority is global to the session, initially the host. The current authority may sdk.session.transfer(peer) to another connected player; the transport host ratifies the transfer asynchronously. The host can reclaim with sdk.session.claim(). The current authority can use sdk.session.teleport(peer, options) to relocate a connected skater. Receivers validate the sender, authority generation, command sequence, and movement values. Reconnect/host changes reset authority; the host reclaims it if its holder leaves. These operations do not implement turns or game rules.

Cameras and world volumes

sdk.camera.watch(peer) follows a remote skater's presented pose; sdk.camera.watch(nil) releases spectating. Camera overrides retain the existing single-mod ownership rule.

sdk.volumes.box(key, {position={x,y,z}, size={x,y,z}, rotation={x,y,z,w}, visible=true, color={r,g,b}, opacity=0.3}) creates or updates an oriented box. sdk.volumes.read(key).inside lists peer IDs whose observed root positions lie inside it. This is a point overlap query, not a collider or a skater hull test. Boxes belong to their creating mod, max 32 per mod; remove with sdk.volumes.remove(key). Changes become observable on the next snapshot.

Trigger volumes (sdk.triggers, capability triggers 1)

The engine tracks the map's named trigger volumes the way retail Skate 3 does: each fixed tick the local player (body "player") and every tracked body get a query cylinder (retail: radius 0.34 m, spanning the skater from just below the feet to just above the head) which is tested against each volume's oriented box. Changes post on_event events before on_fixed_update:

{name="trigger_entered"|"trigger_exited", body="player"|"mod:<mod>:<key>",
 volume=<id>, volume_name=<short name>, group="challenge"|"stairs"|"camera",
 instance_id=<16 hex digits or nil>, link_guid=<16 hex digits or nil>}

Retail map volumes come from the converted map (maps/<Map>.triggers, written by setup); their id is the retail instance id challenge scripts use (e.g. SkateSchool's map-wide tut_sksc_reset_vol01 is 2c7017060025128f). Custom maps carry their own in a <Map>.triggers sidecar or a TVOL extension (same JSON; see docs/hails-additions/24-trigger-volumes.md).

  • sdk.triggers.list() — every volume: {id, name, full_name, group, source="map"|"mod", owner, instance_id, link_guid, center, axes, rotation, half_extents, fatness, aabb_min, aabb_max, enabled, inside={bodies}}. sdk.triggers.get(id_or_name).
  • sdk.triggers.inside(body) — volume ids the body (default "player") is in.
  • sdk.triggers.box(key, {center={x,y,z}, half_extents={x,y,z}, rotation={x,y,z,w}, name="...", group="challenge"}) — a mod volume with id mod:<mod>:<key> (max 64 per mod); sdk.triggers.remove(key).
  • sdk.triggers.set_enabled(id, false) switches a map volume off for everyone until the same mod switches it on or stops.
  • sdk.triggers.track(key, {radius=0.34, length=0}) follows one of the mod's physics bodies (body id mod:<mod>:<key>, max 16); sdk.triggers.untrack(key). The cylinder stands on the body's position (like the player's feet) and reaches length metres up, plus retail's pads (2 cm below, 8 cm above). Positions are taken after the dynamics step, so their events lag one tick.
  • sdk.triggers.configure({radius=, length_scale=, length_pad=, foot_pad=}) changes the query-cylinder constants (one mod at a time); configure() restores retail. The player's cylinder is built like retail's skater from its feet (ground point), head and hips: radius 0.34, half-height length_scale * |head - feet| + length_pad (0.5, 0.05), one end foot_pad (0.02) below the feet.
  • Only challenge volumes post trigger_entered / trigger_exited; retail's stairs and camera groups have no tracked bodies (they are listed, not tracked).

Removing a volume never posts an exit (retail); a body that stops being tracked exits everything it was in. Everything a mod set is undone when it stops; mod volumes, switches and tracked bodies are also cleared when the world changes (re-add them on world_changed).

sdk.camera.capture(key, {position={x,y,z}, look_at={x,y,z}, fov=radians, width=256, height=256}) creates or updates an offscreen camera. FOV defaults to 70 degrees (in radians), range 0.2–2.5. Dimensions must be multiples of 16 between 64 and 512. Limits: 2 captures per mod, 4 overall. Bind a capture using sdk.graphics.mesh_buffer(key, {capture="camera-key"}), then write mesh geometry and UVs with the existing buffer methods. texture and capture are mutually exclusive. Capture surfaces are excluded from capture views to prevent recursive render-target sampling. Clearing/resizing a capture updates its bound materials. Release it with sdk.camera.clear_capture(key).

sdk.graphics.mesh(key, {path="model.glb", opacity=0.5}) supports opacity 0–1, including replication. Camera captures and volumes are local, mod-owned resources and are cleared when their mod stops or the world changes.

Multiplayer names

Edit Your name in the Multiplayer menu. Names are saved to settings/player.json, transmitted to peers, exposed as skater.name, and drawn above remote players. Supported characters: ASCII letters, digits, spaces, hyphens, and underscores, up to 16 characters. Empty names display as Player.

See skate.lua for the complete annotated API. The sample mod in examples/game-of-skate implements game rules using these generic observations.

Mod menus

sdk.ui.menu(key, {title=..., items=...}) registers or replaces a mod-owned menu under Mods. Items have id, label, optional description, enabled, and children. Children create nested pages; leaves emit an action only to the owner. Limits are 8 menus/mod, 64 items/menu, 4 levels, with unique item IDs per menu. Menus support mouse, keyboard, and controller navigation. Remove them explicitly with sdk.ui.remove_menu(key); they also disappear when the mod stops.

return {
  on_load=function()
    sdk.ui.menu("challenges", {title="Challenges", items={
      {id="races", label="Races", children={
        {id="sprints", label="Sprints", children={
          {id="downtown", label="Downtown run"}
        }}
      }}
    }})
  end,
  on_event=function(event)
    if event.name=="menu_action" and event.menu=="challenges" then
      -- The mod owns what selecting downtown does.
      sdk.log("Selected "..event.item)
    end
  end
}

Callbacks run even while paused, so start/stop/settings actions can take effect from the menu. Simulation callbacks remain paused. The S.K.A.T.E. example uses this interface for explicit host-only Start session and Stop session; enabling the mod registers its menu without starting a match.

Native contacts and joint overrides

sdk.player.read().bailing, .mode, .state, and .category describe native player state. sdk.player.physics() adds local ragdoll, partial_ragdoll, parts, joints, and contacts, along with a simulation tick and dt. The convenience methods contacts(), joints(), and parts() return those arrays. Detailed native physics observations are local; remote mods can publish selected effects or results through the generic network API.

Contacts are eligible solved native contact reports, capped at 128/frame. They include both bodies, world point/normal, normal and friction force vectors, combined static/dynamic friction coefficients, and material tags. Force uses the native solver's post-solve conversion, not an estimate from player speed; impulse is that force times simulation dt. These include sustained support forces, not just new impacts. Use the tick to avoid processing a frame twice, and apply your own threshold/history policy. Body kind skater carries a physical part index; external includes attached proxies and target bodies. No injury is inferred by the engine.

Joint entries expose their native names, zero-based IDs, parent/child parts, current angle limits, and override owner. sdk.player.set_joint(joint.index, options) replaces the calling mod's override. Limits are radians, 0.01–pi; free_swing=true, free_twist=true removes angular limits. Also set drive_enabled=false to suppress the child's animation drives. Linear attachment constraints remain intact: the limb becomes loose, not detached. Another mod cannot overwrite an owned joint. Overrides are applied to temporary solver input so native tuning is preserved; reset_joint(index), reset_joints(), mod stop, or world change restores native behavior. Ordinary state transitions and recovery do not erase an override; the mod decides when to restore it.

-- Example policy, entirely in Lua: relax the left elbow during a hard bail.
local last_tick
return {on_fixed_update=function()
  local physics=sdk.player.physics()
  if physics.tick==last_tick then return end
  last_tick=physics.tick
  if not sdk.player.read().bailing then return end
  for _, joint in ipairs(physics.joints) do
    if joint.name=="JOINT_LEFT_ARM_FOREARM" then
      for _, contact in ipairs(physics.contacts) do
        local a,b=contact.a,contact.b
        if (a.kind=="skater" and a.index==joint.child)
          or (b.kind=="skater" and b.index==joint.child) then
          local f=contact.force
          -- An illustrative threshold; calibrate it for your mod's behavior.
          if f[1]^2+f[2]^2+f[3]^2 > 5000^2 then
            sdk.player.set_joint(joint.index,
              {free_swing=true,free_twist=true,drive_enabled=false})
          end
        end
      end
    end
  end
end}

Frame-time statistics

sdk.snapshot.frame is a read-only view of the engine's frame timing (the same numbers as the graphics menu's frame-time counter and the SKATE_FRAME_LOG log): ms (last frame), fixed_steps (physics steps run in that frame), main_ms / fixed_ms (CPU time of the engine's main thread / of the physics steps in that frame), hitch, and over the last window_s seconds fps, mean_ms, median_ms, low_1_ms (1 % low = 99th-percentile frame time), low_01_ms (0.1 % low), worst_ms and hitches (frames longer than twice the median of the previous 120). The window values refresh four times a second. Reading them costs nothing extra; mods cannot change them. Use them for adaptive effects (drop a costly effect while low_1_ms is high) or a custom performance HUD.

UI updates during pause

on_ui_update({dt, paused}) runs every presentation update with a fresh snapshot, including while the pause menu is open. Use it to refresh menus when network roles or other observed state changes. It does not advance sdk.time or simulation timers. sdk.capabilities.menus >= 2 advertises this callback. Keep gameplay in on_fixed_update/on_update. Disabled menu actions show their description and do not invoke callbacks or report a mod failure.

Lua-defined pause sections

Pass section to sdk.ui.menu to place the menu in the main pause sidebar:

sdk.ui.menu("skate", {section="Gamemodes", title="SKATE", items={
  {id="start", label="Start session"}, {id="stop", label="Stop session"}
}})

Section names are supplied by Lua and shared by menus with the same name. The engine imposes no game rules. Omit section to use the Mods menu. Menu removal or mod unload removes its entry; empty sections disappear. Back returns to the section that opened the menu. Limits: 8 custom sections, 64 section menus total, 32 bytes per section name. sdk.capabilities.menus >= 3 supports this placement.

Camera angle and stock shots

The native gameplay camera is Skate 3's stock camera graph. Its Camera Angle setting (pause menu SKATER > Camera angle, settings/camera.json) picks the graph's Low or High branch, which choose different stock shots (bl_chase vs bl_high_chase, ...). With sdk.capabilities.camera >= 4:

  • sdk.camera.angle() returns {selected, active, owner, shot, tuned}: the player's setting, the angle in use, the mod forcing it (or nil), the current stock shot and a shot -> mod table of tuned shots.
  • sdk.camera.set_angle("low" | "high") forces an angle; nil hands it back to the player's setting. One mod at a time; the player's saved choice is untouched.
  • sdk.camera.tune_shot(shot, patch) replaces stock values of one shot (and of every blend tree using it). Keys are the retail attribute names in their units: PositionDistance (m), PositionElevation, PositionHeading, FramingRoll, FramingYaw, FramingPitch (degrees), FramingLensLength, ReferenceBoardOffset, SmoothingDirection, SmoothingElevation, SmoothingYaw, SmoothingPitch, TransitionTime (s). nil restores the stock shot. One mod per shot; unknown shots, keys or out-of-range values fail the command (use sdk.commands.request to get the error back).

Disabling, failing or reloading the mod releases its angle and shot tunings.

Local actor suspension and peer cameras

sdk.player.suspend(true) freezes the local native simulation, hides its skater, and removes its multiplayer collision participation. false releases only the calling mod's suspension. Mod retirement, errors, and world changes release its ownership. Multiple mods may suspend independently. Available with sdk.capabilities.player_control >= 1; skater observations expose suspended.

sdk.camera.watch(peer) mirrors the connected player's transmitted gameplay camera transform and vertical FOV (sdk.capabilities.camera >= 3). Network latency still applies. Older peers without camera packets use the body-follow fallback. nil restores the native camera. This does not transfer player input.

sdk.net.info().host_id is the authenticated host player ID from the roster, not a Steam ID or socket endpoint. It is "0" until the host roster arrives. Use this player ID with sdk.net.read, sdk.player.skater, and camera watching.

The Game of SKATE example keeps its turn roster and saved position in an origin record and its compact changing state in skate. It waits for every participant to acknowledge suspension, then resets the active player using sdk.player.teleport. Only that player's post-reset landing/bail counters count. Late joiners wait for the next match; a missing enabled mod aborts preparation with an actionable status. Stop/unload returns participants to their pre-match positions and restores their cameras. Enable the same package version everywhere.

Native player overlap (capability player_overlap = 1)

sdk.physics.read(key).player_overlapping reports whether the enabled native board/skater collision shapes intersect the mod-owned body. Sensors require intersection; solids include the native solver's 0.025m contact margin. This is an explicit geometric query independent of collision masks, updated before each fixed callback. Suspended/attached players report false. No duplicate Rapier player is simulated. Detect transitions in Lua to count entries rather than counting every frame of sustained contact. It is a sampled query, not a swept trigger: use suitably thick sensor volumes for fast-moving actors.

Native foot-support, air-trajectory and camera queries include mod-created solids, preserving nearest-hit ordering and native map behavior.

Settled scoring details (landed_details = 1)

Player observations include landed_trick_base (engine-authored, unlocalized base label), landed_spin_degrees (signed body rotation, excluding board rotation), landed_clean and landed_sketchy. These are captured together when landing_seq advances and replicated with that observation. They describe the last settled trick; compare sequence numbers to distinguish it from old results. Game rules and label aliases remain in Lua. The existing localized landed_trick remains available for display.

See GENERAL_API.md for native bodies, joint and part overrides, contact lifecycle, input overrides, graph access and acknowledged commands.