Multiplayer Movement logo

Multiplayer Movement

Organization
SummerEngine
multiplayer-movement

Movement in Summer multiplayer: owners move instantly, others glide, the authority checks each pose.

Overview

PublisherSummerEngine
Repositorysummer
Skill namemultiplayer-movement
Stars
91
Forks
8
Bundled files
2
LicenseMIT
Links
  • Markdown instructions

    A SKILL.md file the model loads on demand, so it only costs tokens when a request actually matches.

  • Works with any LLM

    AI skills are plain Markdown, not provider-specific code, so this works with GPT, Claude, Gemini, Grok, or a local model.

  • 2 bundled files

    Scripts, templates, and references the model can read while it works. Files are read-only and never executed.

  • Open source

    Published by SummerEngine on GitHub. Read the source before you install it.

Installation

Install the Multiplayer Movement AI skill in TypingMind to use it with any LLM, or drop it into another agent that reads SKILL.md.

1

Install in TypingMind

TypingMind installs a skill straight from its GitHub folder — it reads SKILL.md, bundles the resource files, and stores the result locally.

  1. Open the app and go to Plugins → Skills.
  2. Choose "Install from GitHub".
  3. Paste the skill folder URL below and confirm.
    https://github.com/SummerEngine/summer/tree/main/library/skills/multiplayer-movement
  4. Enable the skill in any chat where you want it available.
Plugins → Skills → Add skill → From GitHub URL, then paste the folder URL and press Continue.
2

Install in another agent

Any agent that reads the Agent Skills format can use this skill — copy the folder into that agent's skills directory.

Claude Code — .claude/skills
git clone --depth 1 https://github.com/SummerEngine/summer.git /tmp/summer
mkdir -p .claude/skills
cp -r /tmp/summer/library/skills/multiplayer-movement .claude/skills/multiplayer-movement
Restart Claude Code after copying so it picks up the new skill.

Use it in TypingMind

Enable Multiplayer Movement in any TypingMind chat and the model takes it from there. Its name and description sit in the system prompt, and the moment a request matches, the model loads the full instructions itself — you never invoke it by hand, and it costs no tokens until it is actually used.

The model loads Multiplayer Movement on its own as soon as a request matches it.

Works with any AI model

AI skills are plain Markdown instructions rather than provider-specific code, so Multiplayer Movement is not tied to the model it was written for. Install it once in TypingMind and use it with GPT-5, Claude, Gemini, Grok, DeepSeek, Mistral, Llama, or a local model you run yourself — all on your own API keys.

  • Loaded only when it is needed

    The system prompt carries just the name and description. The instructions are fetched on the first matching request, so an idle skill costs nothing.

  • Switch models mid-chat

    Because the skill is instructions rather than code, changing model does not break it — the next model reads the same SKILL.md.

Skill instructions

This is the SKILL.md content the model loads. Read it before installing — a skill is instructions your model will follow.

/multiplayer-movement — each player moves their own character

Step 2 of the multiplayer sequence. It continues the Courtyard game from multiplayer-project; read that first if network/game.gd does not exist. files.md has every file this step changes, complete, as it ran under Local Play.

How it works

Summer games use client-side movement:

  • The owner runs the whole character controller locally: input, walking, gravity, jumping, collision. Its camera follows instantly, at any latency. About 20 times a second it publishes its pose (position, facing) to an owner-written State group.
  • The authority runs no movement. It checks every pose with an acceptance policy: inside the level, no faster than the game allows, still the same player. A refused pose snaps the owner back to the last accepted one. The authority can also move a player on purpose (respawn, portal).
  • Everyone else receives each accepted pose and draws it 100 ms in the past, gliding between poses, so remote players move smoothly through jitter and lost packets.
text
 owner client                 authority                     other clients
 input → controller ──pose──► acceptance policy ──pose──► glide between poses
 camera follows instantly     refuses: owner snaps back    (100 ms behind)

Game rules on the authority (pickups, melee, triggers) read the latest accepted pose (multiplayer-state).

The pose is bound to the player's entity, which the Spawner creates for every joined player. The authority creates the pose group when that entity spawns, which is after the player's network is ready and again after a reconnect. The group ends with the entity.

1. Declare the pose stream

Add a Motion State stream to network/composition.tres, before the Command stream's sub-resources:

ini
[sub_resource type="SummerNetworkField" id="MotionPosition"]
field_name = &"position"
type = 9

[sub_resource type="SummerNetworkField" id="MotionYaw"]
field_name = &"yaw"
type = 2

[sub_resource type="SummerNetworkField" id="MotionEntity"]
field_name = &"entity"
type = 1

[sub_resource type="SummerNetworkSchema" id="MotionSchema"]
schema_id = &"courtyard.motion.v1"
state_fields = Array[SummerNetworkField]([SubResource("MotionEntity"), SubResource("MotionPosition"), SubResource("MotionYaw")])

[sub_resource type="SummerWorldAudience" id="Everyone"]
max_expansion = 8

[sub_resource type="SummerNetworkStateStream" id="Motion"]
stream_id = &"courtyard.motion"
writer_policy = 1
skip_writer_echo = true
state_schema = SubResource("MotionSchema")
audience_policy = SubResource("Everyone")
max_audience_count = 8
max_keys = 8
max_state_bytes = 1024
max_total_bytes = 8192

and list it in the [resource]:

ini
[resource]
network_version = "1.0.0"
state_streams = Array[SummerNetworkStateStream]([SubResource("Motion")])
occurrence_streams = Array[SummerNetworkOccurrenceStream]([SubResource("Commands")])

Update load_steps in the first line to the number of sub-resources plus one. files.md has the complete file.

SettingWhy
writer_policy = 1WRITER_OWNER: a Session writes it, not the authority
skip_writer_echo = trueThe owner is not sent its own accepted poses, only refusals and the authority's moves
audience_policy World, max_expansion = 8, max_audience_count = 8Everyone sees every pose. Set both to at least the player count: 0 reaches nobody
max_keys = 8One key per seat
max_state_bytes = 1024Room for the encoded pose. Too tight and every submission fails as invalid_state
fields entity (1, INT), position (9, VECTOR3), yaw (2, FLOAT)Exactly what others need to draw the player

Field type values are SummerNetworkField.TYPE_*: BOOL 0, INT 1, FLOAT 2, STRING 3, VECTOR2 5, VECTOR3 9, QUATERNION 15, COLOR 19, PACKED_BYTE_ARRAY 20. A pose Dictionary must have exactly the declared fields with exactly those types: no extras, no missing keys, no ints for floats. To send more (an animation state, a carried item), add a field to the schema and to every pose.

2. Shared rules

network/game.gd gains the stream id, the speed limit and the level bounds, after const QUEUE:

gdscript
const MOTION := &"courtyard.motion"
gdscript
## The authority's sanity bound on movement, in metres per second. Keep it
## above the fastest legitimate speed (walking, jumping, falling).
const MAX_SPEED := 15.0
const ARENA_RADIUS := 40.0

and the seat key, the spawn pose and the bounds check, after build_network:

gdscript
## Pose groups are keyed by seat, so a stream needs at most MAX_PLAYERS keys.
static func motion_key(seat: int) -> StringName:
	return StringName("p%d" % seat)


## Seats spawn on a circle. `entity` ties the pose to the player's entity.
static func spawn_pose(seat: int, entity_id: int) -> Dictionary:
	var angle := TAU * float(seat) / MAX_PLAYERS
	return {"entity": entity_id, "position": Vector3(cos(angle) * 4.0, 1.0, sin(angle) * 4.0), "yaw": 0.0}


static func in_bounds(position: Vector3) -> bool:
	return position.is_finite() and Vector2(position.x, position.z).length() <= ARENA_RADIUS \
		and position.y > -10.0 and position.y < 30.0

MAX_SPEED must stay above the fastest legitimate speed, falling included. The authority enforces it; the client never reads it.

3. The authority checks every pose

authority/main.gd:

gdscript
extends Node
## The match server. It runs headless and owns every rule.

const Game = preload("res://network/game.gd")
const PlayerArchetype = preload("res://authority/player_archetype.tres")
## How far a player may move in one burst after a network stall, in metres.
const BURST := Game.MAX_SPEED * 0.5

var spawner: SummerNetworkSpawner
var world: SummerNetworkWorld
var seat_of := {}       # session_id -> seat, held until the Session leaves
var allowance := {}     # seat -> metres the player may still move
var refilled_at := {}   # seat -> msec of the last allowance refill


func _ready() -> void:
	spawner = Game.build_network(self, PlayerArchetype)
	world = $World
	spawner.entity_spawned.connect(_on_player_spawned)
	spawner.command_received.connect(_on_command)
	world.session_left.connect(_on_session_left)
	var initialized: SummerResult = await Summer.initialize().get_result_or_completed_signal()
	if not initialized.ok:
		push_error(initialized.message)
		return
	if not world.is_ready():
		await world.binding_ready
	if not spawner.is_network_ready():
		await spawner.network_ready
	print("COURTYARD_AUTHORITY_READY")


## A seat is a player's place in this World for their whole stay, across
## reconnects. Returns -1 when the World is full.
func _seat(session: SummerSession) -> int:
	if seat_of.has(session.session_id):
		return seat_of[session.session_id]
	var taken := seat_of.values()
	for seat in Game.MAX_PLAYERS:
		if seat not in taken:
			seat_of[session.session_id] = seat
			return seat
	return -1


## The Spawner spawns one player entity per joined player, once that player's
## network is ready (and again after a reconnect). Its owner becomes the only
## writer of the pose; the group ends with the entity.
func _on_player_spawned(entity: SummerNetworkEntity) -> void:
	var seat := _seat(entity.session)
	if seat < 0:
		push_warning("no free seat")
		return
	allowance[seat] = BURST
	refilled_at[seat] = Time.get_ticks_msec()
	var group := spawner.create_state_group(Game.MOTION, Game.motion_key(seat), Game.spawn_pose(seat, entity.entity_id), entity.session, entity)
	group.set_acceptance_policy(_accept_motion.bind(seat))
	print("COURTYARD_PLAYER_SPAWNED seat=%d entity=%d" % [seat, entity.entity_id])


## Runs on every pose an owner submits. True accepts it unchanged; false
## refuses it, and the owner snaps back to the last accepted pose.
func _accept_motion(candidate: Dictionary, current: Dictionary, seat: int) -> bool:
	if candidate.entity != current.entity:
		return false   # a player can move, never become someone else
	var position: Vector3 = candidate.position
	if not Game.in_bounds(position) or not is_finite(candidate.yaw):
		return false
	# A distance allowance refilled at MAX_SPEED: bursts after a stall pass,
	# speed hacks and teleports do not.
	var now := Time.get_ticks_msec()
	var budget := minf(float(allowance[seat]) + Game.MAX_SPEED * (now - int(refilled_at[seat])) / 1000.0, BURST)
	refilled_at[seat] = now
	var distance := position.distance_to(current.position)
	if distance > budget:
		allowance[seat] = budget
		return false
	allowance[seat] = budget - distance
	return true


## Moves a player on purpose (respawn, portal). The owner snaps to it.
func teleport(seat: int, position: Vector3) -> void:
	var group := spawner.get_state_group(Game.MOTION, Game.motion_key(seat))
	if group != null and group.active:
		var pose := group.get_state()
		pose.position = position
		allowance[seat] = BURST
		group.reset(pose)


func _on_command(request: SummerNetworkCommandRequest) -> void:
	var command := Game.unpack(request.payload)
	match str(command.get("c", "")):
		"join":
			_join(request)
		_:
			request.refuse(&"unknown_command")


## A client sends "join" once its network is ready.
func _join(request: SummerNetworkCommandRequest) -> void:
	var seat := _seat(request.get_session())
	if seat < 0:
		request.refuse(&"match_full")
		return
	request.accept({}, Game.pack({"seat": seat}))
	print("COURTYARD_PLAYER_JOINED seat=%d" % seat)


## The Session ended for good: free the seat. (Its pose group already ended
## with the player's entity.)
func _on_session_left(session: SummerSession, reason: String) -> void:
	var seat: int = seat_of.get(session.session_id, -1)
	seat_of.erase(session.session_id)
	print("COURTYARD_PLAYER_LEFT seat=%d reason=%s" % [seat, reason])
  • create_state_group(stream, key, initial, writer_session, entity) makes that Session the only writer, and ties the group's life to the entity.
  • set_acceptance_policy runs synchronously on each pose. Return true to accept it unchanged and false to refuse it. It cannot edit a pose; to move a player, call reset() as teleport() does.
  • The distance allowance refills at MAX_SPEED and holds at most half a second of movement. Poses bunched together after a lag spike pass; teleports and speed hacks don't. A flat per-pose distance check would refuse honest players after every hiccup and still let a cheat move fast in small steps.
  • A seat is the player's place in the World until session_left. Pose groups are keyed by seat, so a stream needs at most MAX_PLAYERS keys.

4. The client: own player and everyone else

client/main.gd pairs each pose group with its entity. Either can arrive first. Add a waiting dictionary:

gdscript
var waiting := {}     # entity id -> pose group that arrived before its entity

connect both signals in _ready() before joining:

gdscript
	# Connect before joining, or the first values arrive unseen.
	spawner.state_group_created.connect(_on_group_created)
	spawner.entity_spawned.connect(_on_entity_spawned)

and add the handlers at the end:

gdscript
func _on_group_created(group: SummerNetworkStateGroup) -> void:
	if group.get_stream_id() == Game.MOTION:
		_attach_pose(group, int(group.get_state().entity))


## The pose group and the player's entity can arrive in either order.
func _attach_pose(group: SummerNetworkStateGroup, entity_id: int) -> void:
	var entity := spawner.get_entity(entity_id)
	if entity == null or entity.root == null:
		waiting[entity_id] = group
		return
	entity.root.bind(group)


func _on_entity_spawned(entity: SummerNetworkEntity) -> void:
	if waiting.has(entity.entity_id):
		_attach_pose(waiting[entity.entity_id], entity.entity_id)
		waiting.erase(entity.entity_id)

The client archetype from multiplayer-project already gives each player an owner scene and an observer scene. Give them scripts.

client/player_owner.gd, your own player:

gdscript
extends CharacterBody3D
## Your own player. You move it locally and publish where it is; the
## authority only snaps you somewhere on spawn, teleport or a refused pose.

const WALK_SPEED := 6.0
const JUMP_VELOCITY := 5.0
const SEND_INTERVAL := 1.0 / 20.0

var group: SummerNetworkStateGroup
var _pose := {}          # the full pose; only position and yaw change
var _sent := {}
var _send_elapsed := 0.0


func bind(motion: SummerNetworkStateGroup) -> void:
	group = motion
	group.state_installed.connect(_on_installed)
	_on_installed(group.get_state(), group.get_revision(), true)


func _on_installed(state: Dictionary, _revision: int, reset: bool) -> void:
	# Accepted poses are not echoed back to you (skip_writer_echo). A reset is
	# the authority moving you: spawn, teleport, or a pose it refused.
	if not reset or state.is_empty():
		return
	_pose = state.duplicate()
	_sent = state.duplicate()
	global_position = state.position
	rotation.y = state.yaw
	velocity = Vector3.ZERO


func _physics_process(delta: float) -> void:
	if group == null or not group.active:
		return
	if not is_on_floor():
		velocity += get_gravity() * delta
	if Input.is_action_just_pressed(&"ui_accept") and is_on_floor():
		velocity.y = JUMP_VELOCITY
	var input := Input.get_vector(&"ui_left", &"ui_right", &"ui_up", &"ui_down")
	velocity.x = input.x * WALK_SPEED
	velocity.z = input.y * WALK_SPEED
	if input != Vector2.ZERO:
		rotation.y = atan2(-input.x, -input.y)
	move_and_slide()
	_publish(delta)


## Publishes the pose 20 times a second, and only when it changed.
func _publish(delta: float) -> void:
	_send_elapsed += delta
	if _send_elapsed < SEND_INTERVAL:
		return
	_send_elapsed = 0.0
	_pose.position = global_position
	_pose.yaw = rotation.y
	if _pose == _sent:
		return
	group.submit(_pose)
	_sent = _pose.duplicate()

Attach it to client/player_owner.tscn (script = ExtResource(...) on the root, as in files.md).

client/player_observer.gd, everyone else:

gdscript
extends Node3D
## Someone else's player. It glides between the poses its owner published,
## drawn VIEW_DELAY in the past so there is always a newer pose to glide to.

const VIEW_DELAY := 0.1

var group: SummerNetworkStateGroup
var _samples: Array = []    # [time, position, yaw] per received pose
var _offsets: Array = []    # recent arrival minus authority acceptance, seconds


func bind(motion: SummerNetworkStateGroup) -> void:
	group = motion
	group.state_installed.connect(_on_installed)
	_on_installed(group.get_state(), group.get_revision(), true)


func _on_installed(state: Dictionary, _revision: int, reset: bool) -> void:
	if state.is_empty():
		return
	var now := Time.get_ticks_usec() / 1000000.0
	if reset:
		_samples.clear()   # a spawn or teleport is a cut, not a slide
		global_position = state.position
		rotation.y = state.yaw
	_samples.append([_pose_time(now), state.position, state.yaw])
	if _samples.size() > 20:
		_samples.pop_front()


func _process(_delta: float) -> void:
	if _samples.is_empty():
		return
	var at := Time.get_ticks_usec() / 1000000.0 - VIEW_DELAY
	var newest: Array = _samples[-1]
	if at >= newest[0] or _samples.size() == 1:
		global_position = newest[1]
		rotation.y = newest[2]
		return
	for i in range(_samples.size() - 1, 0, -1):
		var a: Array = _samples[i - 1]
		var b: Array = _samples[i]
		if at >= a[0]:
			var t := clampf((at - a[0]) / maxf(b[0] - a[0], 0.0001), 0.0, 1.0)
			global_position = (a[1] as Vector3).lerp(b[1], t)
			rotation.y = lerp_angle(a[2], b[2], t)
			return
	global_position = _samples[0][1]


## Places a pose on the local timeline by when the authority accepted it, so
## poses that arrive bunched together still play back evenly.
func _pose_time(now: float) -> float:
	var timing := group.get_timing()
	if timing == null or timing.state_authority_usec < 0:
		return now
	var accepted := timing.state_authority_usec / 1000000.0
	_offsets.append(now - accepted)
	if _offsets.size() > 40:
		_offsets.pop_front()
	return minf(accepted + float(_offsets.min()), now)

Attach it to client/player_observer.tscn the same way.

  • The owner ignores ordinary installs: with skip_writer_echo it is not sent its own accepted poses. A reset install is the authority moving it: spawn, teleport(), or a refused pose.
  • The owner publishes only changed poses, 20 times a second. The engine keeps only the newest pending pose per player, so a faster rate adds traffic, not smoothness.
  • The observer orders poses by when the authority accepted them (get_timing().state_authority_usec), so a burst of late packets still plays back evenly. A reset is a cut, not a slide.
  • Observer scenes have no collision. Other players are drawings on your screen; their owners decide where they are.

5. Test it under latency

Run Local Play with 2 players at 100 ms round trip, 10 ms jitter and 1 % loss (multiplayer-testing shows how):

  • Your own player responds instantly and never snaps back while moving normally.
  • The other window's copy of your player moves smoothly and stops exactly where you stopped.
  • Jumping and falling look the same on both screens.
  • No SCRIPT ERROR, no cannot attach, and every process has "script_errors":0.

To check the anti-cheat, move the owner by 30 m in one frame from a test script. The authority refuses the pose, the owner snaps back, and the other client never sees the jump.

Common mistakes

Don'tDoWhy
Move other players' characters locallyDraw others from their published posesEach player's client is the only source of their position
Create the pose group on session_joinedCreate it in entity_spawnedAt session_joined the player's network is not ready yet, and creation fails
Trust poses uncheckedAn acceptance policy with a refilling distance allowanceOtherwise any client can teleport
A flat "max distance per pose" checkAn allowance refilled at MAX_SPEEDBunched poses after lag get refused, and cheats still move fast
Snap observers to each pose as it arrivesGlide 100 ms behind, ordered by acceptance timePoses arrive unevenly; snapping looks like stutter
Add a key to the pose Dictionary without the schemaAdd the field to the schema tooUndeclared or missing fields fail the submission
max_state_bytes = 2561024 or moreThe bound covers the encoded pose; too tight fails as invalid_state
Collide with observer bodiesLeave observer scenes without collisionAnother player's body is only a drawing on your screen

Next

multiplayer-state: scores, doors, private inventories, and actions the authority checks against these poses.

From a shell

No Summer MCP tools in this session? Run each one as a command; when summer --version is missing or below 4.0, use npx -y summer-engine@latest in place of summer. Fields are flags in kebab-case (--scene-path res://main.tscn) or one --input '<json>', and summer ops describe <op> prints the exact input. The summer-cli skill has the rest.

In this skillCommand
summer_get_diagnosticssummer engine get-diagnostics
summer_get_scene_treesummer engine get-scene-tree
summer_get_script_errorssummer engine get-script-errors
summer_inspect_nodesummer engine inspect-node
summer_playsummer engine play
summer_project_settingsummer engine project-setting
summer_read_filesummer engine read-file
summer_stopsummer engine stop
summer_write_filesummer engine write-file

Bundled files

The model reads these on demand while the skill is loaded. They are exposed as readable files and are never executed.

Frequently asked questions

What does the Multiplayer Movement AI skill do?

Movement in Summer multiplayer: owners move instantly, others glide, the authority checks each pose.

Why use Multiplayer Movement on TypingMind?

Because you install it once and use it with any model. Multiplayer Movement is plain Markdown rather than provider-specific code, so the same skill runs on GPT-5, Claude, Gemini, Grok, or a local model — and you can switch model mid-chat without it breaking. TypingMind runs on your own API keys, so you pay providers directly instead of a per-seat subscription, and your skills and chats stay in your own storage.

How do I install Multiplayer Movement in TypingMind?

Open Plugins → Skills → Install from GitHub in TypingMind and paste https://github.com/SummerEngine/summer/tree/main/library/skills/multiplayer-movement. TypingMind reads its SKILL.md and bundles its files and installs it as a skill you can enable per chat.

Which AI models can use Multiplayer Movement?

Any model you connect in TypingMind. AI skills are plain Markdown instructions rather than provider-specific code, so GPT, Claude, Gemini, Grok, and local models can all load this skill when a request matches it.

How many AI models can I use with Multiplayer Movement?

As many as you like. As long as a model supports skills, you can use Multiplayer Movement with it — GPT, Claude, Gemini, Grok, DeepSeek, Mistral, Llama and more — all on TypingMind with your own API keys.

Is the Multiplayer Movement AI skill free?

Yes. It is published on GitHub by SummerEngine under the MIT license. You only pay your own AI provider for the tokens you use.

What are AI skills?

An AI skill is a reusable instruction bundle that teaches an AI model how to do one specific task. It follows the open Agent Skills format: a SKILL.md file with a name and description, plus any scripts, templates or reference files the model may need. The model reads the instructions only when your request matches the skill, so an installed skill costs nothing until it is used.

How are AI skills different from plugins or MCP servers?

A plugin or MCP server gives a model new tools to call — code that runs somewhere and returns a result. An AI skill gives the model knowledge and process instead: how to approach a task, which steps to follow, what good output looks like. Skills are plain Markdown, so they need no server, no API key and no runtime, and they work with any model.

View all

Set up your own AI workspace now

Get notified about new features and future giveaways by subscribing to our newsletter 👇