Skip to content

Local multiplayer

The local multiplayer sample puts two players in one arena. The top pane follows the cyan player and the bottom pane follows the orange player. Both panes show the same world: head toward the purple cross to meet, or move apart to see each camera follow its own player.

With the build prerequisites installed, run these commands from the repository root in a Visual Studio developer shell:

Terminal window
cmake --preset x64-debug
cmake --build --preset x64-debug --target LocalMultiplayerSample LocalMultiplayerTests
ctest --preset x64-debug -R LocalMultiplayerTests --output-on-failure
out\build\x64-debug\samples\local_multiplayer\LocalMultiplayerSample.exe
Player Keyboard Optional gamepad Pane
1, cyan WASD Slot 0, left stick Top
2, orange Arrow keys Slot 1, left stick Bottom

Escape, or B on either gamepad, exits. No controllers are required. Some keyboards cannot report every combination of simultaneously held keys; use a controller if a combination fails to register. Keys are physical positions, as described in Input.

The build copies all content beside the executable, so it can be launched from any working directory. The default preset needs a Direct3D 11 device; the other rasterising presets use their usual device prerequisites. A null preset records draws without showing a picture.

Player identity belongs to the sample. The same index selects a keyboard binding, a gamepad slot, a position and a view. The state reads both slots without checking connected: an absent slot contributes a neutral stick, leaving keyboard input available.

samples/local_multiplayer/play_state.cpp
const std::array<Vector2F, Arena::player_count> sticks = {
pads.state(0).left_stick, pads.state(1).left_stick
};
std::array<Vector2F, Arena::player_count> directions = read_directions(keyboard, sticks);

The input helper keeps the two sets of keys separate and adds each player’s stick after a deadzone:

samples/local_multiplayer/controls.cpp
std::array<Vector2F, Arena::player_count> read_directions(const Keyboard& keyboard,
const std::array<Vector2F, Arena::player_count>& sticks)
{
const std::array<std::array<Key, 4>, Arena::player_count> bindings = {{
{ Key::a, Key::d, Key::w, Key::s },
{ Key::left, Key::right, Key::up, Key::down }
}};
std::array<Vector2F, Arena::player_count> directions;
for (size_t player = 0; player < Arena::player_count; ++player)
{
Vector2F& direction = directions[player];
direction = apply_deadzone(sticks[player], 0.2f);
if (keyboard.held(bindings[player][0])) direction.x -= 1.0f;
if (keyboard.held(bindings[player][1])) direction.x += 1.0f;
if (keyboard.held(bindings[player][2])) direction.y -= 1.0f;
if (keyboard.held(bindings[player][3])) direction.y += 1.0f;
}
return directions;
}

Arena::set_directions caps each resulting vector to unit length before movement. A diagonal, or holding the keyboard and stick together, therefore cannot exceed the player’s movement speed. Controller slots are fixed here; joining, choosing a slot and reassignment on reconnect are game policy rather than an engine player-management service.

The scene owns one Arena, a GameObject that holds both players. It is admitted at startup, and the state chooses the two-player layout:

samples/local_multiplayer/play_state.cpp
this->scene_ = std::make_unique<Scene>(this->app_->thread_pool(), this->app_->partitioner());
this->arena_ = this->scene_->add(std::make_unique<Arena>(this->white_));
this->scene_->end_tick();
this->app_->viewport_manager()->set_layout(ScreenLayout::two_player);
this->rebuild_views();

Every update supplies the current directions, steps the scene once and then rebuilds its views:

samples/local_multiplayer/play_state.cpp
this->arena_->set_directions(directions);
this->scene_->update(dt);
this->scene_->end_tick();
this->rebuild_views();

The number of views does not change simulation time. Two panes mean two drawings of the same positions. The sample clamps players at the arena boundary; that is movement policy. For contact detection and resolution, continue with Collision.

ViewportManager’s two_player layout makes top and bottom panes from the current window size. The sample clears and refills the scene’s view list after movement, so cameras track the new positions and the layout follows window resizing:

samples/local_multiplayer/play_state.cpp
void PlayState::rebuild_views()
{
this->scene_->clear_views();
for (size_t player = 0; player < Arena::player_count; ++player)
{
const Viewport viewport = this->app_->viewport_manager()->player_viewport(
static_cast<int>(player));
this->scene_->add_view(viewport, this->arena_->camera(player, viewport));
}
}

The camera’s translation is the player’s world position minus half the pane size. At scale 1, this puts the player at the pane centre:

samples/local_multiplayer/arena.cpp
Camera Arena::camera(size_t player, const Viewport& viewport) const
{
return Camera(this->position(player) - viewport.size() * 0.5f, 1.0f);
}

The viewport’s back-buffer offset is separate. In particular, the bottom player’s camera does not add the bottom pane’s y-offset: the camera maps into pane coordinates, and the viewport places that pane on the buffer. Near an arena edge the camera shows the empty space beyond it.

ApplicationOptions::view_capacity is 2, reserving exactly the two recordings the layout needs. The thread pool ceiling is a separate choice, capped at two workers for this sample:

samples/local_multiplayer/main.cpp
options.view_capacity = 2;
options.max_threads = std::min(2, default_thread_count());

The scene borrows the shell’s thread pool and partitioner, so each view can be recorded on its own worker. Both workers call Arena::draw on the same object. That method is const; its temporary drawing values are locals, and positions change only in update.

The HUD runs after the world, on that view’s worker. It selects the player from view_index and switches the list to the identity camera, keeping text fixed in its pane while the world moves beneath it:

samples/local_multiplayer/play_state.cpp
this->scene_->draw(renderer, [this](int view_index, DrawList& list)
{
const size_t player = static_cast<size_t>(view_index);
const Viewport& viewport = this->scene_->view(view_index).viewport;
list.set_camera(Camera::DEFAULT_CAMERA);

The rest of the callback draws the player’s controls and a coloured separator. It reads shared state and writes only its own DrawList. This small scene uses fan-out to demonstrate the contract, rather than as a performance claim: constructing Scene(nullptr, nullptr) draws the same two views serially. See Drawing, views and cameras for the rendering model.

LocalMultiplayerTests drives input routing, player movement and following cameras without a window or device. With x64-debug-null, it additionally draws the real arena into the recording backend and compares serial and parallel output, including both players in both views.

For a finite check of the selected renderer and the sample’s actual state:

Terminal window
out\build\x64-debug\samples\local_multiplayer\LocalMultiplayerSample.exe --smoke-test

This renders 120 scripted ticks in a hidden window, checks the two independent final positions and view count, and exits with a nonzero status on failure. It does not test physical input. Add --capture out\multiplayer.png to save an early frame on a rasterising backend; the destination’s parent directory must exist. The sample’s README describes its scope and verification in full.

Development documentation (unreleased). Built from Labrador 862e08b of 2026-10-08.