A Polytopia-like 4X strategy game in one self-contained index.html. Vanilla JS, isometric canvas renderer, no build step, no assets, no npm dependencies.
- Solo / hotseat: open
index.htmlin a browser. - Online multiplayer:
node server.jsthen openhttp://localhost:8765(or your LAN/public address). Hit Play online, create a room, share the 4-letter code.PORT=3000 node server.jsto change ports.
server.js is a single zero-dependency Node file that both serves the game over HTTP and speaks WebSocket on the same port (RFC-6455 framing implemented by hand, so there is no npm install). It is authoritative: it runs the same rules code extracted from index.html, and every client action is validated server-side (correct seat + legal under applyActionObj) before being broadcast to both clients, which apply it deterministically to their local copies. Room flow:
- Player A: Create room → gets a code like
KQZW, waits. - Player B: Join room with the code → server creates the game and sends both players the full state + their seat.
- Actions stream as tiny JSON messages; turn order is enforced server-side. Disconnects keep the room alive — rejoin with the same code and the server re-sends the full current state.
To host for friends over the internet: run it on any box with Node and a reachable port (a $4 VPS, fly.io, a Tailscale/ngrok tunnel from your laptop — anything that can expose TCP works, no other infra needed). Honest caveat for competitive play: clients hold the full state and render through their own fog, so a determined cheater could read the opponent's positions from devtools — fine among friends; server-side fog filtering is the eventual fix.
- Modes: vs Robot (heuristic or RL policy), 2-player hotseat (pass-the-device blackout screen), or online via room code.
- Seed: same seed = same map.
- Camera: drag to pan, wheel to zoom, shift-drag (or right-drag) to tilt the view between top-down and side-on.
⌖resets. - Click a unit → blue tiles move, red diamonds attack (hover for damage preview both ways).
- Click your city → harvest/train panel, and its territory lights up on the map.
- Capturing: move onto a village or enemy city; next turn a gold flag appears and a Capture button/action becomes available. Capturing is optional and consumes the unit's turn.
- Win: capture the enemy capital, or higher score at turn 30.
Enterends turn,Escdeselects. - Save/Load (Menu) exports the entire game as a base64 string.
- Tech tree (15 nodes): hunting→(archery, forestry→mathematics); farming→shields; climbing→mining→smithery; riding→free spirit→chivalry; fishing→sailing→navigation. Forestry builds lumber huts on bare forest (3★ → 1 pop); mathematics unlocks the catapult; fishing harvests fish (2★ → 1 pop); sailing lets units enter water as rafts (no attacking or retaliating at sea); navigation makes water stop costing the whole move and grants vision 2 at sea.
- Unit stats are the published values (warrior 2★ 10hp 2/2, archer 3★ 10hp 2/1 rng2, defender 3★ 15hp 1/3, rider 3★ 10hp 2/1 mv2, swordsman 5★ 15hp 3/3, knight 8★ 10hp 3.5/1 mv3, catapult 8★ 10hp 4/0 rng3 no skills, giant 40hp 5/4).
- Your own units never block harvesting/building on a tile — only enemy occupation matters (a unit standing on a resource shows the harvest button in its panel).
- Combat uses the published formula verbatim (
atk × hp/maxHpvsdef × hp/maxHp × defBonus, ×4.5, rounded). - Defense bonus: 1.5 forest (archery) / mountain (climbing) / own city, 4.0 with wall. City bonuses require the fortify skill — giants never get them. Besieging units get no bonus.
- Besieged cities (enemy unit on them) produce no income.
- Capture is an explicit action: the unit must start its turn on the city tile (so earliest is the turn after arriving), and may choose to do something else instead.
- Fog is one-time: once a tile is explored it stays fully visible forever, including enemy units on it (as in the real game).
- No retaliation if: defender dies, attacker out of range, or the attacker stands on a tile the defender has never scouted.
- Melee units take the tile on a kill; ranged units never move.
- Idle units heal +4/turn in own territory, +2 elsewhere. 3 kills → veteran (+5 max HP, full heal).
Sis one plain JSON-serializable state object; every rule is a pure function of it (reachable,calcCombat,applyMove/Attack/Harvest/Research/Train/Reward,endTurnLogic...).render()only readsS+ a localuiobject. The logic layer never touches the DOM — it runs headless in Node, which is how it's tested and how RL training works.
The game embeds a gym-style bridge mirroring the polytopia_rl repo's interfaces:
enumerateActions(S)/applyActionObj(S, a)— deterministic ordered action list (likelistActionsJson/stepByIndex), including the explicitCAPTUREaction (same as Tribes' ACTION set). When a city levels up, the only legal actions are the two reward choices.encodeObs(S, p)— same layout asregister_env._dict_to_array:[terrain(256), unitID(256), cityID(256), stars, score, numCities, kills, tick, activeTribeID]= 774 floats, with Tribes terrain codes (PLAIN=0, WATER=1, MOUNTAIN=3, VILLAGE=4, CITY=5, FOREST=6).makePPOAgent(weights)— hand-written forward pass of the CleanRL PPO actor (Linear(774,64) → Tanh → Linear(64,64) → Tanh → Linear(64,200)), with the sameDiscrete(200)modulo action masking asTribesGymWrapper.step.
The polytopia_rl repo has no saved checkpoints, and a model trained on the Java Tribes env wouldn't transfer (different board semantics + action ordering). Instead, train directly against this engine:
- Env server —
node rl/web_env_server.jsspeaks a JSON-lines gym protocol (reset/step, reward = Δscore/100 + relative/1000 − 0.01, ±100 terminal — same shape asgym_env.py). The agent is player 0; the heuristic robot plays player 1. - Python wrapper —
rl/polytopia_web_env.pyis a drop-ingym.Env(same obs/action spaces asTribesGymWrapper). Point themake_envthunk inpy_rl/clean_rl_nc/ppo.pyatPolytopiaWebEnvand train as usual. Smoke test:python rl/polytopia_web_env.py(needsgymnasium,numpy, Node on PATH). - Export —
torch.save(agent.state_dict(), "ppo.pth"), thenpython rl/export_weights.py ppo.pth weights.json. - Play it — in the game: Menu → RL weights… → paste
weights.json, then start a game with Robot brain = RL policy.
Random-weight policies work mechanically (verified) but play badly — the loader exists so trained weights drop straight in.
A game is (seed, list of actions) and applyActionObj already validates every action against state, so it doubles as a server-side rule check. Remaining work: a tiny WebSocket room relay; reconnect = send the base64 save string.
node --check on the extracted script, full heuristic-vs-heuristic games across seeds, mechanics unit checks (wall ×4, fortify, siege income, fog retaliation, mountain gating), action-enumeration fuzz (random playouts — every enumerated action must apply legally), PPO forward pass + full RL-vs-heuristic games, gym-server protocol round-trip, deterministic mapgen, save/load identity.