Repository
Theria
A 2.5D MOBA built in Godot 4 — shapeshifter tribes clash across savanna and jungle under a server-authoritative, deterministic simulation.
- 2d-game
- deterministic-simulation
- game
- gamedev
- gdscript
- godot
- godot4
- indie-game
- moba
- multiplayer-online-battle-arena
- server-authoritative
main
Languages
- GDScript 94.8%
- Markdown 3.7%
- Shell 0.8%
- YAML 0.7%
- JSON 0.0%
Readme
Shapeshifter tribes clash over the savanna and the jungle — fight as both human and beast
About
Theria is a top-down multiplayer online battle arena built in Godot 4, set in a world of shapeshifters — tribes whose members fight in both a human and an animal form. Two teams of three, each drawn from a shapeshifter people, contest savanna lanes and an equatorial jungle to break each other's nexus.
The first milestone is a walking skeleton: one player-controlled hero and
one bot moving on the 3v3 arena under a server-authoritative, fixed-timestep
simulation. With that authority model proven, networked play over a
listen-server, the hero ability layer, and two full rosters of heroes — the
Solane, savanna big-cat shifters (lion, cheetah, hyena), and the opposing
Verdani, jungle venom-and-shadow shifters (snake, spider, chameleon) — now
run on top of it. A practice match fields one tribe against the other — --hero
picks any hero, the player drives it and bots fill out both squads — so both
rosters are on the field at once. The Verdani fight by attrition: their venom
lingers as damage over time and their webs slow what they catch, a foil to the
Solane's burst. Multi-hero teams over the wire and the art direction come next.
Playtesting
Theria self-updates, so you install it once and always launch the latest build.
- Download the launcher for your platform from the
latest release —
Theria-windows.zip(Windows) orTheria-macos.zip(macOS). - Unzip and run it. On launch it briefly checks for a newer build, downloads it if there is one, and starts. Every build after that arrives automatically — you never re-download.
It is offline-safe: with no connection it simply starts the build you already have, and an update never touches your settings or saved data.
macOS builds are unsigned for now, so Gatekeeper blocks the first launch. Clear the quarantine flag once, then open the app normally:
xattr -dr com.apple.quarantine /path/to/Theria.app
Building Theria yourself instead of playtesting a release? See Running.
Architecture
The simulation is the single source of truth. SimCore is a deterministic,
side-effect-free step function that advances the world by a fixed 1/60 s tick
from input alone — no rendering, no engine input, no global state. The same
core is driven by:
- the local client (
src/client), which samples the keyboard and draws the resulting state; - the bot (
src/bot), which derives its command from the world state; - the headless tests (
test/), which replay scripted input and assert the outcome; - the networked drivers (
src/net), where a host simulates and broadcasts the world and a client sends its input up and renders the snapshots it receives.
Because authority lives entirely in the simulation, networked play is just another driver — a listen-server — added without rewriting gameplay. The host is the sole authority. A client never owns authority, but it predicts its own hero locally so input feels instant, reconciling against every snapshot: it rolls back to the server's state and replays the inputs the server has not yet applied, using the same movement code the server runs. Remote units — the enemy hero, creeps, and structures — are rendered a short delay in the past, interpolated between buffered snapshots, so they move smoothly through network jitter and dropped packets; that delay adapts to the connection's measured jitter rather than being fixed.
Layout
| Path | Contents |
|---|---|
src/sim |
The authoritative simulation core, its data types, and the data-driven hero ability layer. |
src/bot |
Bot input derived from the world state. |
src/net |
Listen-server transport, the client/server wire protocol, remote-entity interpolation, and the playtest link-condition simulator. |
src/client |
The title screen, the boot/update screen, local input sampling, and rendering. |
src/update |
The in-client auto-updater — manifest logic and the build download/swap. |
test/unit |
Headless tests of the simulation and the wire protocol. |
scenes |
Godot scenes. |
assets |
Art assets — the placeholder hero models (see CREDITS.md). |
Running
Open the project in Godot 4.6 and press Play, or from the command line:
godot --path .
A connect screen opens: choose Practice for a single-machine match, Host to
start a listen-server, or type an address and Join one. Practice is a tribe-vs-tribe
match: pick the hero you drive from the menu's roster list — any hero of either tribe — and
that hero's tribe fields your team while the opposing tribe fills the bots, so picking the
snake puts you on the Verdani against the Solane, and the default lion keeps the Solane
against the Verdani. The command line's --hero makes the same choice for a launch that
skips the menu. Bots drive the other five seats. A hosted or joined match is still a
one-hero-per-team duel on the lion until multi-hero play
reaches the wire. Move the hero by right-clicking where it should go — click-to-move, like a MOBA — and the bots fight to their kit's
stance — brawlers close on the nearest enemy and shift into the form that keeps a hit
in reach (into the animal kit when an enemy slips inside the human poke, back to the
human form to poke or heal), while the skirmishers (Cheetah, Chameleon) hold their
poke range and back off rather than melee — and all cast their own kits, healing when
hurt and otherwise firing the reachable ability of their form. Cast its abilities with
Q W E R, aimed at the mouse cursor — the hero shifts between a human and an animal
form (shown by the ring around it, white or amber), each form a different set of
abilities drawing on its own resource (the bar under the health bar). Each hero appears as
its own animal — a placeholder low-poly model washed in its team colour — so your three
squadmates read apart by species at a glance. The corner minimap is live: right-click it to
send your hero across the map, left-click (or drag) to pan the camera for a free look, and press
Space to snap back to your hero. Abilities are
cast in a single-machine or hosted match; a joined client moves but does not yet
cast.
Multiplayer
The connect screen's Host and Join cover multiplayer. The same roles can be selected on the command line, which skips the menu — this is how a headless run picks a role, since a menu cannot be driven without a display:
godot --path . -- --host # host the match (you are team 0)
godot --path . -- --join 127.0.0.1 # join a host at an address (you are team 1)
godot --path . -- --local # a single-machine practice match, no menu
godot --path . -- --local --hero snake # drive a Verdani hero (your team fields the Verdani)
The host is authoritative and fills any empty player slot with a bot. The joining player's hero is predicted locally, so it responds without waiting on the host.
A local machine and a clean LAN deliver snapshots almost perfectly, so the smoothing that exists to ride out a bad connection is never really exercised. To see it work, a joining player can simulate a worse link on their incoming snapshot stream:
# join with 150 ms latency, 50 ms of jitter, and 10% packet loss
godot --path . -- --join 127.0.0.1 --netsim 150,50,0.1
This shapes only what the client receives — it changes nothing the host sends and no wire bytes — and makes the remote units visibly buffer further behind and the interpolation cover the dropped snapshots. It is a debug aid, not a gameplay option.
Testing
Tests run headless with GUT:
godot --headless -s addons/gut/gut_cmdln.gd -gdir=res://test -ginclude_subdirs -gexit
Linting uses the GDScript toolkit:
gdlint src test
Both run in continuous integration on every push and pull request.
License
Apache License 2.0 — see LICENSE. Bundled third-party art assets carry
their own licenses, credited in CREDITS.md.
See also
- FlashOS — AArch64 bare-metal kernel for the Raspberry Pi 4B and QEMU.
- Flash — a systems language and Zig transpiler.
- the-way-out — top-down pixel-art escape-room shooter.
- eeco — self-maintaining workflow ecosystem.
Recent commits
-
fd0de9av0.4.3 Jun 2026 -
93c6764fix: footer names the running content version, add manual update check Jun 2026 -
6d7493dfeat: walk through towers — they no longer block movement Jun 2026 -
a1793ccv0.4.1 Jun 2026 -
d7d59c0feat: in-client error screen with codes for connection failures Jun 2026 -
4c188b8feat: units walk over hills instead of clipping through — flatter relief Jun 2026 -
2072faav0.4.0 Jun 2026 -
6ceaef9feat: interactive minimap — right-click to move, left-click to pan camera Jun 2026 -
b8a65defeat: walls block line of sight in fog of war Jun 2026 -
994efe2feat: corner minimap honouring per-team fog of war Jun 2026 -
ea92e79feat: server-authoritative fog of war — radius vision, per-team snapshots Jun 2026 -
192d6e2feat: server-authoritative collision + click-to-move auto-pathing Jun 2026 -
f863da2feat: jungle map decor — mountains, palms, camps, walls, mirrored Jun 2026 -
dcf967fv0.3.4 Jun 2026 -
342723echore: add the run dev-launcher shell helpers Jun 2026
Files
-
.github
-
workflows
-
-
addons
-
gut
-
fonts
- AnonymousPro-Bold.ttf
- AnonymousPro-Bold.ttf.import
- AnonymousPro-BoldItalic.ttf
- AnonymousPro-BoldItalic.ttf.import
- AnonymousPro-Italic.ttf
- AnonymousPro-Italic.ttf.import
- AnonymousPro-Regular.ttf
- AnonymousPro-Regular.ttf.import
- CourierPrime-Bold.ttf
- CourierPrime-Bold.ttf.import
- CourierPrime-BoldItalic.ttf
- CourierPrime-BoldItalic.ttf.import
- CourierPrime-Italic.ttf
- CourierPrime-Italic.ttf.import
- CourierPrime-Regular.ttf
- CourierPrime-Regular.ttf.import
- LobsterTwo-Bold.ttf
- LobsterTwo-Bold.ttf.import
- LobsterTwo-BoldItalic.ttf
- LobsterTwo-BoldItalic.ttf.import
- LobsterTwo-Italic.ttf
- LobsterTwo-Italic.ttf.import
- LobsterTwo-Regular.ttf
- LobsterTwo-Regular.ttf.import
- OFL.txt
-
gui
- about.gd
- about.gd.uid
- about.tscn
- arrow.png
- arrow.png.import
- editor_globals.gd
- editor_globals.gd.uid
- EditorRadioButton.tres
- gut_config_gui.gd
- gut_config_gui.gd.uid
- gut_dock.gd
- gut_dock.gd.uid
- gut_gui.gd
- gut_gui.gd.uid
- gut_logo.gd
- gut_logo.gd.uid
- gut_user_preferences.gd
- gut_user_preferences.gd.uid
- GutBottomPanel.gd
- GutBottomPanel.gd.uid
- GutBottomPanel.tscn
- GutControl.gd
- GutControl.gd.uid
- GutControl.tscn
- GutLogo.tscn
- GutRunner.gd
- GutRunner.gd.uid
- GutRunner.tscn
- GutSceneTheme.tres
- MinGui.tscn
- NormalGui.tscn
- option_maker.gd
- option_maker.gd.uid
- OutputText.gd
- OutputText.gd.uid
- OutputText.tscn
- panel_controls.gd
- panel_controls.gd.uid
- play.png
- play.png.import
- ResizeHandle.gd
- ResizeHandle.gd.uid
- ResizeHandle.tscn
- ResultsTree.gd
- ResultsTree.gd.uid
- ResultsTree.tscn
- run_from_editor.gd
- run_from_editor.gd.uid
- run_from_editor.tscn
- RunAtCursor.gd
- RunAtCursor.gd.uid
- RunAtCursor.tscn
- RunExternally.gd
- RunExternally.gd.uid
- RunExternally.tscn
- RunResults.gd
- RunResults.gd.uid
- RunResults.tscn
- Settings.tscn
- ShellOutOptions.gd
- ShellOutOptions.gd.uid
- ShellOutOptions.tscn
- ShortcutButton.gd
- ShortcutButton.gd.uid
- ShortcutButton.tscn
- ShortcutDialog.gd
- ShortcutDialog.gd.uid
- ShortcutDialog.tscn
-
images
- eyey.png
- eyey.png.import
- Folder.svg
- Folder.svg.import
- green.png
- green.png.import
- GutIconV2_base.png
- GutIconV2_base.png.import
- GutIconV2_no_shine.png
- GutIconV2_no_shine.png.import
- HSplitContainer.svg
- HSplitContainer.svg.import
- red.png
- red.png.import
- Script.svg
- Script.svg.import
- VSplitContainer.svg
- VSplitContainer.svg.import
- yellow.png
- yellow.png.import
- autofree.gd
- autofree.gd.uid
- awaiter.gd
- awaiter.gd.uid
- collected_script.gd
- collected_script.gd.uid
- collected_test.gd
- collected_test.gd.uid
- comparator.gd
- comparator.gd.uid
- compare_result.gd
- compare_result.gd.uid
- diff_formatter.gd
- diff_formatter.gd.uid
- diff_tool.gd
- diff_tool.gd.uid
- double_tools.gd
- double_tools.gd.uid
- doubler.gd
- doubler.gd.uid
- dynamic_gdscript.gd
- dynamic_gdscript.gd.uid
- editor_caret_context_notifier.gd
- editor_caret_context_notifier.gd.uid
- error_tracker.gd
- error_tracker.gd.uid
- get_editor_interface.gd
- get_editor_interface.gd.uid
- godot_singletons.gd
- godot_singletons.gd.uid
- gut_cmdln.gd
- gut_cmdln.gd.uid
- gut_config.gd
- gut_config.gd.uid
- gut_fonts.gd
- gut_fonts.gd.uid
- gut_loader_the_scene.tscn
- gut_loader.gd
- gut_loader.gd.uid
- gut_menu.gd
- gut_menu.gd.uid
- gut_plugin.gd
- gut_plugin.gd.uid
- gut_to_move.gd
- gut_to_move.gd.uid
- gut_tracked_error.gd
- gut_tracked_error.gd.uid
- gut_vscode_debugger.gd
- gut_vscode_debugger.gd.uid
- gut.gd
- gut.gd.uid
- GutScene.gd
- GutScene.gd.uid
- GutScene.tscn
- hook_script.gd
- hook_script.gd.uid
- icon.png
- icon.png.import
- inner_class_registry.gd
- inner_class_registry.gd.uid
- input_factory.gd
- input_factory.gd.uid
- input_sender.gd
- input_sender.gd.uid
- junit_xml_export.gd
- junit_xml_export.gd.uid
- lazy_loader.gd
- lazy_loader.gd.uid
- LICENSE.md
- logger.gd
- logger.gd.uid
- menu_manager.gd.uid
- method_maker.gd
- method_maker.gd.uid
- one_to_many.gd
- one_to_many.gd.uid
- orphan_counter.gd
- orphan_counter.gd.uid
- parameter_factory.gd
- parameter_factory.gd.uid
- parameter_handler.gd
- parameter_handler.gd.uid
- plugin.cfg
- printers.gd
- printers.gd.uid
- result_exporter.gd
- result_exporter.gd.uid
- script_parser.gd
- script_parser.gd.uid
- signal_watcher.gd
- signal_watcher.gd.uid
- singleton_parser.gd
- singleton_parser.gd.uid
- source_code_pro.fnt
- source_code_pro.fnt.import
- spy.gd
- spy.gd.uid
- strutils.gd
- strutils.gd.uid
- stub_params.gd
- stub_params.gd.uid
- stubber.gd
- stubber.gd.uid
- stubs.gd
- stubs.gd.uid
- summary.gd
- summary.gd.uid
- test_collector.gd
- test_collector.gd.uid
- test.gd
- test.gd.uid
- thing_counter.gd
- thing_counter.gd.uid
- UserFileViewer.gd
- UserFileViewer.gd.uid
- UserFileViewer.tscn
- utils.gd
- utils.gd.uid
- version_conversion.gd
- version_conversion.gd.uid
- version_numbers.gd
- version_numbers.gd.uid
- warnings_manager.gd
- warnings_manager.gd.uid
-
-
assets
-
models
-
creeps
-
-
-
src
-
client
- boot.gd
- boot.gd.uid
- cel.gdshader
- cel.gdshader.uid
- combat_fx.gd
- combat_fx.gd.uid
- connect_menu.gd
- connect_menu.gd.uid
- death_overlay.gd
- death_overlay.gd.uid
- error_codes.gd
- error_codes.gd.uid
- error_overlay.gd
- error_overlay.gd.uid
- fog_overlay.gd
- fog_overlay.gd.uid
- fog.gdshader
- fog.gdshader.uid
- foliage.gdshader
- foliage.gdshader.uid
- ground.gdshader
- ground.gdshader.uid
- hero_model_library.gd
- hero_model_library.gd.uid
- jungle_decor.gd
- jungle_decor.gd.uid
- kill_feed.gd
- kill_feed.gd.uid
- main.gd
- main.gd.uid
- map_view.gd
- map_view.gd.uid
- match_camera.gd
- match_camera.gd.uid
- match_chat.gd
- match_chat.gd.uid
- match_fx.gd
- match_fx.gd.uid
- match_hud.gd
- match_hud.gd.uid
- match_overlays.gd
- match_overlays.gd.uid
- minimap.gd
- minimap.gd.uid
- move_marker.gd
- move_marker.gd.uid
- path.gdshader
- path.gdshader.uid
- player_input.gd
- player_input.gd.uid
- settings.gd
- settings.gd.uid
- shadow.gdshader
- shadow.gdshader.uid
- status_label.gd
- status_label.gd.uid
- ui_theme.gd
- ui_theme.gd.uid
- water.gdshader
- water.gdshader.uid
-
sim
- ability_data.gd
- ability_data.gd.uid
- ability_executor.gd
- ability_executor.gd.uid
- ability_spec.gd
- ability_spec.gd.uid
- input_command.gd
- input_command.gd.uid
- map_data.gd
- map_data.gd.uid
- nav_grid.gd
- nav_grid.gd.uid
- sim_core.gd
- sim_core.gd.uid
- sim_entity.gd
- sim_entity.gd.uid
- sim_state.gd
- sim_state.gd.uid
- vision.gd
- vision.gd.uid
-
test
-
unit
- test_ability_fx.gd
- test_ability_fx.gd.uid
- test_ability.gd
- test_ability.gd.uid
- test_bot_controller.gd
- test_bot_controller.gd.uid
- test_bot_difficulty.gd
- test_bot_difficulty.gd.uid
- test_connect_menu.gd
- test_connect_menu.gd.uid
- test_death_overlay.gd
- test_death_overlay.gd.uid
- test_error_codes.gd
- test_error_codes.gd.uid
- test_fog_overlay.gd
- test_fog_overlay.gd.uid
- test_kill_feed.gd
- test_kill_feed.gd.uid
- test_local_seating.gd
- test_local_seating.gd.uid
- test_map_data.gd
- test_map_data.gd.uid
- test_match_chat.gd
- test_match_chat.gd.uid
- test_match_hud.gd
- test_match_hud.gd.uid
- test_minimap.gd
- test_minimap.gd.uid
- test_nav_grid.gd
- test_nav_grid.gd.uid
- test_net_protocol.gd
- test_net_protocol.gd.uid
- test_net_sim.gd
- test_net_sim.gd.uid
- test_prediction.gd
- test_prediction.gd.uid
- test_settings.gd
- test_settings.gd.uid
- test_sim_core.gd
- test_sim_core.gd.uid
- test_snapshot_interpolator.gd
- test_snapshot_interpolator.gd.uid
- test_solane.gd
- test_solane.gd.uid
- test_status_effects.gd
- test_status_effects.gd.uid
- test_update_manifest.gd
- test_update_manifest.gd.uid
- test_verdani.gd
- test_verdani.gd.uid
- test_vision.gd
- test_vision.gd.uid
-
- .gitattributes
- .gitignore
- .gutconfig.json
- CHANGELOG.md
- CREDITS.md
- env.zsh
- export_presets.cfg
- gdlintrc
- icon.svg
- icon.svg.import
- LICENSE
- project.godot
- README.md
- VERSION
- wordmark.svg
- wordmark.svg.import