Cold Takes/20 - Teamwork By Default
Zero-K pushes the limit of team games in RTS, supporting up to 16 players per side - something that sounds impossible. Your average RTS caps out at 4v4, with a few going as high as 6v6. In Zero-K players regularly play games three times this size, and while such large games are not for everyone, I suggest trying them out just for the experience. There is a real sense of scale in a game with so many players, taking so many actions, in which you can only really zoom in and understand a relatively small part. But it is surprising that such games work at all, so in this article we are going to dive into the design that makes them possible.
The weirdest part of the story is that, for the most part, support for large team games came about accidentally. The rest of the Zero-K design philosophy just happens to inherently let teams have a large number of players. From time to time we added features aimed at large games, but most of the work is done by principles covered in previous cold takes. We could have deliberately removed large teams, but the attempt would warp the rest of the design, and it is not like they are hurting other game modes. Small games, down to 1v1 and 2v2, work fine, and the way I see it, supporting more game modes is good.
The bulk of the work is done by Fight your opponent, not the UI, plus the realisation that players coordinate with their team via the user interface. Essentially, cooperation within a team should be free of artificial limitations or hoops to jump through, in much the same way as any other action in the game. Zero-K contains simple examples of cooperation UI, such as the nuke indicator that tells players where an allied nuke is expected to land, but not fighting the UI goes deeper than that.
Zero-K is the game that designs the stupidity out of units and uses designs weapons using unattainable ideals, and we take the same approach to cooperation. Like units, players have abilities that influence the game, and we consider it a failure if the mechanics of the game force players down the route of boring and arduous use of the UI. So while most games would happily add a nuke indicator, few would go the extra step of excising fundamentally anti-cooperative mechanics to the extent that we do.
We inherited key player abilities, namely the ability to freely share units and resources, from Total Annihilation. This is a fairly standard function for RTS games, but the implications are far-reaching, and Zero-K has a penchant for taking ideas to the extreme and biting the resulting bullets. The issue with sharing is that sharing UIs are invariably some of the worst found in RTS. They tend to resemble big spreadsheets of fiddly buttons or sliders, which is not what we want in an RTS. So contact with this UI should be minimised, which relegates sharing to an almost "theoretical" ability, one that the game is designed around, but which players should rarely manually invoke. This generalises to the principle of the irrelevance of ownership:
- Nothing in the mechanics of the game world is permitted to depend on the details of ownership within a team, because then players would be incentivised to touch the unit sharing UI to gain an advantage.
Few games use this principle, since it is automatically violated by player-specific upgrades. Even within the TA-lineage, Supreme Commander lets units of the same player shoot through each other, but not through allies. Even Zero-K violates the principle, since unidentified radar dots reveal team colour, which can let the enemy guess the identity of unseen units. This is something I have wanted to be able to turn off, but it requires engine work, and to walk back the extremism a bit, dot colour may well improve the game.
The principle of irrelevance of ownership sounds restrictive, but it is also quite freeing, and we were not going to add global upgrades regardless. A key implication of the principle is that resource income can be distributed arbitrarily within a team, without any theoretical impact on in-world game balance. In practise this let us fiddle with income sharing to "simulate" cooperation among strangers on the internet, which is part of what allows such large team games to work. For example, income from metal extractors is shared evenly between everyone on the team, which was called communism mode in early Complete Annihilation. Communism evolved alongside the rest of the game, and quickly became integrated with overdrive, since the overdrive formula benefits from shared resources.
The theoretical justification for communism is that securing territory is a shared effort, and much harder than merely building a metal extractor, so the team shares the benefit. The practical justification was more important at the time though, since we all had experience with people arguing over metal spots in Balanced Annihilation, which could escalate to teamkilling. A secondary effect in BA was the limited map pool; players tended to play a small set of maps, where everyone understood the meta of spot ownership, in part to lessen the potential for arguments over resources.
It is worth mentioning that I snuck an assumption into communism: that players want to cooperate. What if people want to keep their income to themselves? This gets at the core of the design: we assume that the team wants to cooperate, teamwork is the default. If players want to argue and destroy each other's buildings, then they might not find support for these actions in the UI. There is also a bit of a paradox here, as anyone who has played a team game with a range of skill levels can tell you: who owns what is actually very important. But instead of trying to judge the worth of each player, we opted to make a game where part of the skill is in being a good teammate, rather than rolling over the weaker members on your team as you grow your own personal empire.
Communism solved the metal spot arguments and opened up the map pool, but it suffered from the free rider problem. Some players would skip building metal extractors all together, hoping that their teammates would pick up the slack. Full communism was also not great at incentivising overdrive, even when it would significantly benefit the team. So we added rebates. For example, metal extractors refund 80% of their cost to the player(s) that built them, over a few minutes, in the form of a slightly increased share of the income. Energy structures offer a similar refund of 60% from their increased metal overdrive income. The numbers have been tweaked over the years, since a higher payback percentage can lead to perceptions of selfishness by energy spammers. We even recently introduced payback for the first few Antinukes built by a team, as a social way to nerf nukes in low-cooperation situations.
Shared metal extractors also helped with the "Tabula Problem" caused by some rotationally symmetric maps. Tabula has high terrain on its North and South, that is easier to take and defend from the North-West and South-East respectively. Before communism, Tabula was typically won by the team that invaded the weak low ground from the strong high ground, before their opponent did the same on the other side of the map. The low ground just lacked the metal income required to mount a defense, without players manually sharing units or metal extractors. Tabula continued to play a bit like this since communism, the low ground still has poor terrain, but the extra income makes the defense fairer and more interesting.
Income sharing alone is not enough to make large games fun. For that we need the rest of Zero-K, primarily its unit and faction design. Our large number of faction-like factories lets players carve out a distinct role and set of units within the team, and factory plop lets everyone build a factory without "wasting" resources. There was concern that the recent addition of factory plates would destroy this sense of having a role within a team, but the fear has not come to pass. If anything they seem to have improved team games by letting players take on a role they are not sure of, with the safety of being able to switch out of it cheaply.
The coordination feature that came closest to breaking the game was shared control mode, where players own and control the same set of units. The campaign has shared control, but it can also be enabled in team games via an invite system. The danger with shared unit control is that it can be powerful, but it is also quite annoying when someone else accidentally uses "your" units. This is the dreaded trade-off, power at the cost of fighting the UI, but the mode is barely used in public games. Tournament and campaign games are different, since people can communicate enough to avoid the downsides, and in these contexts it is a great feature.
As with many automation-type features, it is worth asking whether cooperation has been automated away. I think we are fine on this front; teammates send messages, flank, and otherwise help each other out. People still share the occasional unit to augment fronts that really need it. Mostly we have removed the excess UI interactions, and set a baseline level of cooperation so players can expand as much as they want without worrying about "stealing" metal spots. Perhaps the greatest benefit is too the map pool, as any map can be played with any number of players, for a really wide range of experiences.
This marks the end of the first series of cold takes. Twenty is a nice round number and it is time for a short break. Look forward to the next series starting in January next year.
Debug data:
[SQLBagOStuff] MainObjectStash using store ReplicatedBagOStuff
[objectcache] MainWANObjectCache using store EmptyBagOStuff
IP: 3.139.237.94
Start request GET /mediawiki/Cold_Takes/20_-_Teamwork_By_Default
HTTP HEADERS:
CONTENT-TYPE:
CONTENT-LENGTH: 0
X-ORIGINAL-URL: /mediawiki/Cold_Takes/20_-_Teamwork_By_Default
USER-AGENT: Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; ClaudeBot/1.0; +claudebot@anthropic.com)
HOST: zero-k.info
ACCEPT-ENCODING: gzip, br, zstd, deflate
ACCEPT: */*
CONNECTION: close[localisation] LocalisationCache: using store LCStoreDB
[session] SessionManager using store SqlBagOStuff
[DBReplication] Cannot use ChronologyProtector with EmptyBagOStuff
[DBReplication] Wikimedia\Rdbms\LBFactory::getChronologyProtector: request info {
"IPAddress": "3.139.237.94",
"UserAgent": "Mozilla\/5.0 AppleWebKit\/537.36 (KHTML, like Gecko; compatible; ClaudeBot\/1.0; +claudebot@anthropic.com)",
"ChronologyProtection": false,
"ChronologyPositionIndex": 0,
"ChronologyClientId": false
}[DBConnection] Wikimedia\Rdbms\LoadBalancer::lazyLoadReplicationPositions: executed chronology callback.
[DBConnection] Wikimedia\Rdbms\LoadBalancer::getLocalConnection: connected to database 0 at 'localhost'.
[SQLBagOStuff] Connection mysql object #127 (handle id #121) will be used for SqlBagOStuff
[session] SessionBackend "f1ots67vqo2n8k3n1ri0jr0t920o1he6" is unsaved, marking dirty in constructor
[session] SessionBackend "f1ots67vqo2n8k3n1ri0jr0t920o1he6" save: dataDirty=1 metaDirty=1 forcePersist=0
[cookie] already deleted setcookie: "wikidb229_mw__session", "", "1710217825", "/", "", "", "1"
[cookie] already deleted setcookie: "wikidb229_mw_UserID", "", "1710217825", "/", "", "", "1"
[cookie] already deleted setcookie: "wikidb229_mw_Token", "", "1710217825", "/", "", "", "1"
[cookie] already deleted setcookie: "forceHTTPS", "", "1710217825", "/", "", "", "1"
[DBConnection] Wikimedia\Rdbms\LoadBalancer::getLocalConnection: connected to database 0 at 'localhost'.
Title::getRestrictionTypes: applicable restrictions to [[Cold Takes/20 - Teamwork By Default]] are {edit,move}
[ContentHandler] Created handler for wikitext: WikitextContentHandler
[MessageCache] MessageCache using store SqlBagOStuff
[localisation] LocalisationCache::isExpired(en): cache for en expired due to GlobalDependency
[localisation] LocalisationCache::recache: got localisation for en from source
[DBQuery] startAtomic: entering level 0 (LCStoreDB::finishWrite)
[DBQuery] endAtomic: leaving level 0 (LCStoreDB::finishWrite)
[SQLBagOStuff] Connection mysql object #127 (handle id #121) will be used for SqlBagOStuff
[SQLBagOStuff] Connection mysql object #127 (handle id #121) will be used for SqlBagOStuff
[SQLBagOStuff] Connection mysql object #127 (handle id #121) will be used for SqlBagOStuff
[SQLBagOStuff] Connection mysql object #127 (handle id #121) will be used for SqlBagOStuff
[SQLBagOStuff] SqlBagOStuff::lock failed due to timeout for wikidb229-mw_:messages:en.
[SQLBagOStuff] Connection mysql object #127 (handle id #121) will be used for SqlBagOStuff
[SQLBagOStuff] Connection mysql object #127 (handle id #121) will be used for SqlBagOStuff
[MessageCache] MessageCache::load: Loading en... local cache is empty, global cache is expired/volatile, loading from database
ParserFactory: using preprocessor: Preprocessor_Hash
Unstubbing $wgLang on call of $wgLang::_unstub from ParserOptions->__construct
[caches] parser: SqlBagOStuff
Article::view using parser cache: yes
[SQLBagOStuff] Connection mysql object #127 (handle id #121) will be used for SqlBagOStuff
Article::view: doing uncached parse
[SQLBagOStuff] Connection mysql object #127 (handle id #121) will be used for SqlBagOStuff
[Preprocessor] Cached preprocessor output (key: wikidb229-mw_:preprocess-hash:7ff1c7daee7c63c178ae51d4254c6d96:0)
[objectcache] Rejected set() for wikidb229-mw_:page:10:62473c79b69e43e489a3d7e851f6e8629f0832da due to pending writes.
[objectcache] Rejected set() for global:revision-row-1.29:wikidb229-mw_:1640:10032 due to pending writes.
[Preprocessor] Cached preprocessor output (key: wikidb229-mw_:preprocess-hash:17aa7002849cc6ce738a62fa8e371aed:1)
[objectcache] Rejected set() for wikidb229-mw_:page:10:1eea3d5309d2a88c1e83cbfafba24489c41a09ad due to pending writes.
[objectcache] Rejected set() for global:revision-row-1.29:wikidb229-mw_:1979:10035 due to pending writes.
[objectcache] Rejected set() for wikidb229-mw_:page:828:3df63b7acb0522da685dad5fe84b81fdd7b25264 due to pending writes.
[objectcache] Rejected set() for global:revision-row-1.29:wikidb229-mw_:78:981 due to pending writes.
[ContentHandler] Created handler for Scribunto: ScribuntoContentHandler
[Scribunto] Scribunto_LuaStandaloneInterpreter::__construct: creating interpreter: ""C:\Projekty\zero-k.info\www\mediawiki\extensions\Scribunto\includes\engines\LuaStandalone/binaries/lua5_1_5_Win64_bin/lua5.1.exe" "C:\Projekty\zero-k.info\www\mediawiki\extensions\Scribunto\includes\engines\LuaStandalone/mw_main.lua" "C:\Projekty\zero-k.info\www\mediawiki\extensions\Scribunto\includes" "0" "8""
[gitinfo] Candidate cacheFile=C:\Projekty\zero-k.info\www\mediawiki/gitinfo.json for C:\Projekty\zero-k.info\www\mediawiki
[gitinfo] Cache incomplete for C:\Projekty\zero-k.info\www\mediawiki
SiteStats::loadAndLazyInit: reading site_stats from replica DB
[objectcache] Rejected set() for wikidb229-mw_:SiteStats:groupcounts:sysop due to pending writes.
[objectcache] Rejected set() for wikidb229-mw_:file:73af53ccad147c77191d984a0352b7bfb895e391 due to pending writes.
[objectcache] Rejected set() for wikidb229-mw_:page:6:0ae294637e786b4cd0de3292296cb151c7fb8edd due to pending writes.
[objectcache] Rejected set() for wikidb229-mw_:file:0ae294637e786b4cd0de3292296cb151c7fb8edd due to pending writes.
[objectcache] Rejected set() for wikidb229-mw_:page:10:719ea396ad92e01b4757ec2b93bb1e5f270f771d due to pending writes.
[objectcache] Rejected set() for global:revision-row-1.29:wikidb229-mw_:12:79 due to pending writes.
[Mime] MimeAnalyzer::loadFiles: loading mime types from C:\Projekty\zero-k.info\www\mediawiki\includes/libs/mime/mime.types
[Mime] MimeAnalyzer::loadFiles: loading mime info from C:\Projekty\zero-k.info\www\mediawiki\includes/libs/mime/mime.info
File::transform: Doing stat for mwstore://local-backend/local-thumb/d/d1/20_-_Teamwork_By_Default.jpg/1px-20_-_Teamwork_By_Default.jpg
TransformationalImageHandler::doTransform: creating 1x1 thumbnail at mwstore://local-backend/local-thumb/d/d1/20_-_Teamwork_By_Default.jpg/1px-20_-_Teamwork_By_Default.jpg using scaler im
TransformationalImageHandler::doTransform: Transforming later per flags.
File::transform: Doing stat for mwstore://local-backend/local-thumb/d/d1/20_-_Teamwork_By_Default.jpg/2px-20_-_Teamwork_By_Default.jpg
TransformationalImageHandler::doTransform: creating 2x1 thumbnail at mwstore://local-backend/local-thumb/d/d1/20_-_Teamwork_By_Default.jpg/2px-20_-_Teamwork_By_Default.jpg using scaler im
TransformationalImageHandler::doTransform: Transforming later per flags.
File::transform: Doing stat for mwstore://local-backend/local-thumb/d/d1/20_-_Teamwork_By_Default.jpg/2px-20_-_Teamwork_By_Default.jpg
TransformationalImageHandler::doTransform: creating 2x1 thumbnail at mwstore://local-backend/local-thumb/d/d1/20_-_Teamwork_By_Default.jpg/2px-20_-_Teamwork_By_Default.jpg using scaler im
TransformationalImageHandler::doTransform: Transforming later per flags.
[objectcache] Rejected set() for wikidb229-mw_:file:f1434024a1f8ed7bd7c3d551241855c8201087bc due to pending writes.
File::transform: Doing stat for mwstore://local-backend/local-thumb/4/4b/World_map_with_icons.png/800px-World_map_with_icons.png
[FileOperation] FileBackendStore::ingestFreshFileStats: File mwstore://local-backend/local-thumb/4/4b/World_map_with_icons.png/800px-World_map_with_icons.png does not exist
TransformationalImageHandler::doTransform: creating 800x450 thumbnail at C:\Windows\TEMP\mwtmp-IUSR/transform_6944ec6c96ba.png using scaler im
TransformationalImageHandler::doTransform: returning unscaled image
File::transform: Doing stat for mwstore://local-backend/local-thumb/4/4b/World_map_with_icons.png/800px-World_map_with_icons.png
TransformationalImageHandler::doTransform: creating 800x450 thumbnail at C:\Windows\TEMP\mwtmp-IUSR/transform_393b53ec5d00.png using scaler im
TransformationalImageHandler::doTransform: returning unscaled image
File::transform: Doing stat for mwstore://local-backend/local-thumb/4/4b/World_map_with_icons.png/800px-World_map_with_icons.png
TransformationalImageHandler::doTransform: creating 800x450 thumbnail at C:\Windows\TEMP\mwtmp-IUSR/transform_5f83ac41438b.png using scaler im
TransformationalImageHandler::doTransform: returning unscaled image
[objectcache] Rejected set() for wikidb229-mw_:file:fcc20dfbad96f32a99fdd8c35def036bff3f8144 due to pending writes.
File::transform: Doing stat for mwstore://local-backend/local-thumb/3/3e/Allied_indicators.png/800px-Allied_indicators.png
[FileOperation] FileBackendStore::ingestFreshFileStats: File mwstore://local-backend/local-thumb/3/3e/Allied_indicators.png/800px-Allied_indicators.png does not exist
TransformationalImageHandler::doTransform: creating 800x450 thumbnail at C:\Windows\TEMP\mwtmp-IUSR/transform_14b9318e8994.png using scaler im
TransformationalImageHandler::doTransform: returning unscaled image
File::transform: Doing stat for mwstore://local-backend/local-thumb/3/3e/Allied_indicators.png/800px-Allied_indicators.png
TransformationalImageHandler::doTransform: creating 800x450 thumbnail at C:\Windows\TEMP\mwtmp-IUSR/transform_658831332e9c.png using scaler im
TransformationalImageHandler::doTransform: returning unscaled image
File::transform: Doing stat for mwstore://local-backend/local-thumb/3/3e/Allied_indicators.png/800px-Allied_indicators.png
TransformationalImageHandler::doTransform: creating 800x450 thumbnail at C:\Windows\TEMP\mwtmp-IUSR/transform_86ca5432fe2d.png using scaler im
TransformationalImageHandler::doTransform: returning unscaled image
[objectcache] Rejected set() for wikidb229-mw_:file:305bc7837ea2e138d92c55f0087c650faca68f6d due to pending writes.
File::transform: Doing stat for mwstore://local-backend/local-thumb/5/58/Commie_mode.jpg/800px-Commie_mode.jpg
[FileOperation] FileBackendStore::ingestFreshFileStats: File mwstore://local-backend/local-thumb/5/58/Commie_mode.jpg/800px-Commie_mode.jpg does not exist
TransformationalImageHandler::doTransform: creating 800x450 thumbnail at C:\Windows\TEMP\mwtmp-IUSR/transform_4c2184b9a573.jpg using scaler im
TransformationalImageHandler::doTransform: returning unscaled image
File::transform: Doing stat for mwstore://local-backend/local-thumb/5/58/Commie_mode.jpg/800px-Commie_mode.jpg
TransformationalImageHandler::doTransform: creating 800x450 thumbnail at C:\Windows\TEMP\mwtmp-IUSR/transform_9bcfd11e36ac.jpg using scaler im
TransformationalImageHandler::doTransform: returning unscaled image
File::transform: Doing stat for mwstore://local-backend/local-thumb/5/58/Commie_mode.jpg/800px-Commie_mode.jpg
TransformationalImageHandler::doTransform: creating 800x450 thumbnail at C:\Windows\TEMP\mwtmp-IUSR/transform_05fec913503f.jpg using scaler im
TransformationalImageHandler::doTransform: returning unscaled image
[objectcache] Rejected set() for wikidb229-mw_:file:55690cf3ad97055cb900ba4024bfbcd012c2fde9 due to pending writes.
File::transform: Doing stat for mwstore://local-backend/local-thumb/4/4c/Rotationally_symmetric.png/800px-Rotationally_symmetric.png
[FileOperation] FileBackendStore::ingestFreshFileStats: File mwstore://local-backend/local-thumb/4/4c/Rotationally_symmetric.png/800px-Rotationally_symmetric.png does not exist
TransformationalImageHandler::doTransform: creating 800x450 thumbnail at C:\Windows\TEMP\mwtmp-IUSR/transform_b032ecb434ff.png using scaler im
TransformationalImageHandler::doTransform: returning unscaled image
File::transform: Doing stat for mwstore://local-backend/local-thumb/4/4c/Rotationally_symmetric.png/800px-Rotationally_symmetric.png
TransformationalImageHandler::doTransform: creating 800x450 thumbnail at C:\Windows\TEMP\mwtmp-IUSR/transform_b01eb617de65.png using scaler im
TransformationalImageHandler::doTransform: returning unscaled image
File::transform: Doing stat for mwstore://local-backend/local-thumb/4/4c/Rotationally_symmetric.png/800px-Rotationally_symmetric.png
TransformationalImageHandler::doTransform: creating 800x450 thumbnail at C:\Windows\TEMP\mwtmp-IUSR/transform_e5ee2bfabce4.png using scaler im
TransformationalImageHandler::doTransform: returning unscaled image
[objectcache] Rejected set() for wikidb229-mw_:file:f2eb4fe5e9d28f1382adf87d38b4f95a755002f8 due to pending writes.
File::transform: Doing stat for mwstore://local-backend/local-thumb/d/d1/Multiple_Factories.png/800px-Multiple_Factories.png
[FileOperation] FileBackendStore::ingestFreshFileStats: File mwstore://local-backend/local-thumb/d/d1/Multiple_Factories.png/800px-Multiple_Factories.png does not exist
TransformationalImageHandler::doTransform: creating 800x450 thumbnail at C:\Windows\TEMP\mwtmp-IUSR/transform_36a866ac7051.png using scaler im
TransformationalImageHandler::doTransform: returning unscaled image
File::transform: Doing stat for mwstore://local-backend/local-thumb/d/d1/Multiple_Factories.png/800px-Multiple_Factories.png
TransformationalImageHandler::doTransform: creating 800x450 thumbnail at C:\Windows\TEMP\mwtmp-IUSR/transform_ffd3a2f3cf87.png using scaler im
TransformationalImageHandler::doTransform: returning unscaled image
File::transform: Doing stat for mwstore://local-backend/local-thumb/d/d1/Multiple_Factories.png/800px-Multiple_Factories.png
TransformationalImageHandler::doTransform: creating 800x450 thumbnail at C:\Windows\TEMP\mwtmp-IUSR/transform_88591d78325e.png using scaler im
TransformationalImageHandler::doTransform: returning unscaled image
[Preprocessor] Cached preprocessor output (key: wikidb229-mw_:preprocess-hash:7ff1c7daee7c63c178ae51d4254c6d96:0)
Saved in parser cache with key wikidb229-mw_:pcache:idhash:1985-0!dateformat=default and timestamp 20250312043025 and revision id 10056
[SQLBagOStuff] Connection mysql object #127 (handle id #121) will be used for SqlBagOStuff
[SQLBagOStuff] Connection mysql object #127 (handle id #121) will be used for SqlBagOStuff
MediaWiki::preOutputCommit: primary transaction round committed
MediaWiki::preOutputCommit: pre-send deferred updates completed
MediaWiki::preOutputCommit: session changes committed
MediaWiki::preOutputCommit: LBFactory shutdown completed
File::transform: Doing stat for mwstore://local-backend/local-thumb/4/4b/World_map_with_icons.png/800px-World_map_with_icons.png
TransformationalImageHandler::doTransform: creating 800x450 thumbnail at C:\Windows\TEMP\mwtmp-IUSR/transform_49f2264a77cb.png using scaler im
TransformationalImageHandler::doTransform: returning unscaled image
Title::getRestrictionTypes: applicable restrictions to [[Cold Takes/20 - Teamwork By Default]] are {edit,move}