Status: feature-frozen, pinned to pygamine v0.24.0. Not actively developed.
Standoff is a 2D networked multiplayer top-down shooter built with pygame-ce. Pick a character, host or join a room over the network (or play offline), then move, aim, and shoot your way through waves of zombies on a Tiled-authored map.
Enter a name, pick one of 8 characters, then choose how to play:
- New Game — play offline on your own.
- Create Room — host an online room others can join (up to 4 players).
- Connect — join an existing room by its room ID.
Players and zombies start with 100 HP. Zombies home in on the nearest player (falling back to your base when out of range); shooting kicks back and emits a muzzle flash. The camera follows your player around the map.
| Main menu | Character select | Gameplay |
|---|---|---|
![]() |
![]() |
![]() |
hitman · man_blue · man_brown · man_old · robot · solider · survivor · woman_green
| Action | Input |
|---|---|
| Move | W A S D or arrow keys |
| Aim | Mouse |
| Shoot | Left click (hold to auto-fire) |
| Back / menu | Esc |
| Toggle debug overlay | F1 |
| Toggle fullscreen | F11 |
The host runs a dedicated server window:
uv run python server.py # or: scripts/server.batClients launch the game, Create Room (host) or Connect by room ID (others). On the same LAN, clients connect directly to the host's IP. For play over the internet you'll need to reach the server's TCP port from outside — a mesh VPN like Tailscale (stable, encrypted, no port‑forwarding), a game‑oriented tunnel like playit.gg, or router port‑forwarding. (A TCP tunnel such as ngrok works too, but its free tier hands out a new address each session — fetch it yourself; it's no longer committed to the repo.)
Grab a ready-to-play desktop build for your OS from itch.io or the latest GitHub release — no Python required. Unzip and run:
| OS | Run |
|---|---|
| Windows | Extract standoff-windows.zip, run standoff.exe |
| macOS | Extract standoff-macos.zip, open Standoff.app |
| Linux | Extract standoff-linux.zip, run ./standoff/standoff |
macOS Gatekeeper: the app is unsigned, so the first launch needs right-click → Open (or
xattr -dr com.apple.quarantine Standoff.app).Windows SmartScreen: the app is unsigned, so the first launch shows "Windows protected your PC." Click More info → Run anyway. This is Microsoft's download-reputation check, not a virus warning — brand-new unsigned executables always trigger it.
An Android debug APK is also built on every release — see .github/workflows/android.yml — but it's a CI artifact only, not yet attached to Releases or itch.io.
- Python 3.12+
- pygame-ce, colorama, pyyaml, pytmx (resolved automatically from
pyproject.toml/uv.lock) - uv (optional but recommended)
git clone --recurse-submodules https://github.com/vectorviolet/standoff.git
cd standoff
uv sync
uv run python __main__.pyIf you forgot --recurse-submodules: git submodule update --init.
Without uv: pip install . then python __main__.py (Windows: scripts/Standoff.bat).
Desktop builds are produced by PyInstaller from standoff.spec, which bundles assets/ and config/ alongside the executable (onedir) and excludes the tkinter-based server. To build locally for your current OS:
uv sync --group build
uv run pyinstaller standoff.spec --noconfirmThe result lands in dist/standoff/ (dist/Standoff.app on macOS).
Per-OS bundles for Windows, macOS, and Linux are built and published automatically by .github/workflows/release.yml when a version tag is pushed:
git tag v0.1.0
git push origin v0.1.0The desktop workflow also pushes each OS build to its itch.io channel via Butler. The same tag also triggers .github/workflows/android.yml, which builds a debug APK as a separate CI artifact (not attached to the Release or to itch.io). Use either workflow's Run workflow button to test a build without publishing.
__main__.py Entry point — injects src/ + src/pygamine/ into sys.path
server.py Dedicated multiplayer server (Tk window)
src/app/game.py Game class — owns the active scene + network client, drives the loop
src/app/scene.py Scene base — the per-frame handle_event/update/draw contract
src/app/lobby_scene.py LobbyScene — menus, character select, room create/join
src/app/gameplay_scene.py GameplayScene — the in-world arena, entities, camera, game loop
src/gameplay/ Entities (player, mob, bullet), map, camera, collision
src/net/ Client + server networking, room / player state, protocol commands
src/ui/ Panel widgets (vector buttons, text input)
src/util/ Constants, database helper
src/pygamine/ Engine submodule (Application, GameObject/ECS, PanelLoaderExt, ...)
config/ YAML: assets, panels
assets/ Images, Tiled maps
tests/ Test suite — protocol/transport/game-server, unit → e2e
bench/ Headless performance benchmarks (mob capacity)
scripts/ Windows launchers (Standoff.bat, server.bat, build-android.ps1)
tools/ Build helpers (build_android.sh)
Standoff runs on the shared pygamine engine: Game extends pygamine.Application, the menus are panel-driven (config/panels.yaml + PanelManager), and in-world entities are GameObjects with SpriteRenderer2D components.
- Scenes.
Gameis a thin shell that holds the active scene and forwardshandle_event/update/drawto it.LobbySceneowns all menu/panel state and the pre-game flow;GameplaySceneowns the in-world phase. Switching phases swaps the active scene instead of branching on flags — a pause screen or game-over screen is a newScenesubclass, not anotherif. - The World.
GameplaySceneis the world that entities depend on:Player,Mob,Bullet,MuzzleFlash,MapandObstacleread their surroundings (walls, players, mobs,delta_time, camera, the mob grid, …) off that narrow surface rather than reaching into the wholeGame. - Networking. A game-agnostic transport (
BaseClient/BaseServer+ a pluggableProtocol/Codec) carries pickled messages. Both client (Game.get_data) and server (GameServer._on_message) dispatch through handler dicts keyed on the shared command names innet/commands.py, so the two sides can't silently drift apart.
Tests are organized as a pyramid over the networking stack:
- Unit (
test_protocol.py,test_game_server.py) — no sockets. Framing/codec edge cases and game-server command dispatch via fakes. Fast. - Integration (
test_transport.py, markedintegration) — realBaseClient/BaseServerover loopback. - End-to-end (
test_e2e.py, markede2e) — fullGameServer+ clients.
uv run --group dev pytest # everything
uv run --group dev pytest -m "not integration and not e2e" # fast unit tests onlyCI (GitHub Actions, .github/workflows/tests.yml) runs the full suite on every push and on PRs into main.
bench/bench_mobs.py is a headless capacity benchmark — how many zombies the
simulation sustains before missing the 60 FPS frame budget. It times the per-frame
update (AI/physics) and draw separately for increasing mob counts:
uv run python bench/bench_mobs.pyNumbers are machine-relative — use them to compare before/after an optimization on the same machine, not as an absolute FPS promise.
A pre-commit hook runs ruff (lint + format) and the fast tests on each commit, leaving the socket-backed tests to CI. Enable it once per clone:
uv run pre-commit installBypass a hook run with git commit --no-verify; run all hooks manually with uv run pre-commit run --all-files.
Character and tile art from Kenney — Topdown Shooter. UI button sounds from Kenney UI Pack. See CREDITS.md for every asset pack and library with its license; it also ships with the game.
Every file under assets/ is listed in assets/SOURCES.yaml with its source:
- A pack, given by its slug (for example
kenney-ui-pack). The entry also gives the file's path inside that pack asoriginal. Images built from several pack files list them undercomposited_frominstead. Edited pack files say what changed undermodified. own, for files made for this game.unknown, for files whose origin or license isn't confirmed yet. Each needs atodothat says what to do; the credits list them as "not yet verified".
The packs section names each pack, its author, URL, license and what the game uses it for.
Each pack's own license text is in licenses/assets/<slug>.txt, and every build ships it.
When you add, replace or remove an asset:
- Update
assets/SOURCES.yaml, and addlicenses/assets/<slug>.txtfor any new pack. - Run
uv run python scripts/gen_credits.pyto regenerate CREDITS.md's asset section. Don't edit that section by hand. - Run the tests.
tests/test_sources.pyfails if a file is missing fromSOURCES.yaml, if a listed file doesn't exist, if a pack has no license text, or if CREDITS.md is out of date.
Only ship assets whose license allows bundling them in a distributed game; resolve every
unknown entry before the next release.
- Fork this repository.
- Clone your fork:
git clone --recurse-submodules https://github.com/<you>/standoff.git - Set up dev tooling:
uv syncthenuv run pre-commit install(runs lint/format + fast tests on commit). - Create a branch:
git checkout -b feature/<your-feature> - Commit + push:
git commit -am "<message>" && git push origin feature/<your-feature> - Open a pull request.
Umutcan Ekinci — umutcannekinci@gmail.com
See also the contributors.
See LICENSE.



