← All posts
Oct 9, 2026

FC Tank in 2026: Friends Play, a Global Leaderboard, and an AI-Guided NIGHTMARE Mode

My 2013 Battle City clone came back with online co-op over WebRTC, a global leaderboard, and a NIGHTMARE difficulty where TypeSafe's Jev picks each enemy tank's objective every two seconds.

Ben Cao · 9 min read · 0 comments

Back in 2013 I wrote a Battle City clone in HTML5 canvas and CoffeeScript. Thirteen years later I picked it up again. It's modern JavaScript now, lives at fc-tank.bencao.it, and sits inside a 1980s arcade cabinet: a CRT set with scanlines and a phosphor glow, plus an FC-style gamepad on phones.

Three features changed how the game plays, and each one taught me something:

  • Friends play: online two-player co-op from an invitation link.
  • A global leaderboard: arcade initials, top ten, team entries.
  • NIGHTMARE: a difficulty level where an AI model, TypeSafe's Jev, decides what every enemy tank is trying to do.

Friends play

Pick FRIENDS PLAY on the title screen and the game opens a room and shows a link like https://fc-tank.bencao.it/?join=29FKXF. Your friend opens it, you press ENTER, and you play the classic 2P game together, each in your own browser.

FC Tank's FRIENDS PLAY screen: 'INVITE A FRIEND', the room code 29FKXF in orange, and 'SEND THE LINK BELOW – WAITING...'

Peer to peer, with a tiny signalling step

The two browsers talk over a WebRTC data channel, so game traffic never goes through my servers. They only need help finding each other, and that's a small endpoint, /api/room, backed by Upstash Redis:

  1. The host creates an RTCPeerConnection, gathers all its ICE candidates, and posts the offer. It gets back a room code.
  2. The guest opens the link, reads the offer, and puts its answer. The first guest wins; anyone after that gets "ROOM IS FULL".
  3. The host, polling once a second, picks up the answer and the channel opens.

I used non-trickle ICE: gather every candidate first, then send the offer once. Setup is a little slower, but it means one offer and one answer, two Redis writes in total. Rooms expire after 30 minutes. There's no TURN relay, so somewhere between 10% and 20% of NAT pairs can't connect, and the lobby says "COULDN'T CONNECT" instead of hanging. Adding TURN later is a one-line config change.

Why the host runs the whole game

The obvious design for a networked game is lockstep: both sides run the same simulation and only exchange inputs. But a 2013 game engine is full of Math.random() calls and variable frame times, and lockstep would have meant rewriting it to be deterministic.

So the host is authoritative. Only the host runs the battle. The guest is a remote keyboard plus a screen that mirrors the host:

MessageDirectionWhat it carries
keyguest → hostA key going down or up. These drive the 2P tank.
scenehost → guestWhich screen to show: stage, battle, report…
viewhost → guestCalls to an allow-list of view methods, replayed on the guest's own copies
framehost → guestEvery tank, missile and power-up, plus terrain changes and explosions
soundhost → guestWhich sound to play

Frames go out at most every 33 ms and weigh about 1–2 KB. Terrain is sent as a diff against the last frame (a brick wall shot away, a shovel turning walls to steel), so everything goes over one reliable, ordered channel. Missing a diff would leave the guest's map out of sync for good.

Both friends use the 1P keys (arrows and Z) on their own keyboards. If the guest's window loses focus, its held keys are released, so its tank can't drive off on its own. If one of you runs out of tanks, pressing fire borrows a spare life from the other.

A global leaderboard

After a game over you enter three initials, arcade style, and see the top ten with your run marked. A friends play run counts as one team, BEN&CAT, with the two scores added up. When the title screen is left alone, it alternates between the high scores and a demo game.

FC Tank's HIGH SCORES board: ten rows of initials, score, stage and difficulty, including friends-play team entries such as BEN&CAT, AAA&JER and BEN&RUY next to solo HARD and NIGHTMARE runs

It's one Redis sorted set. Each run is a member holding a random id plus the initials, stage and difficulty, scored by its points. The id matters: without it, two runs with the same initials, stage and difficulty would be the same member and the second would overwrite the first. The server keeps the best 100 and serves the top 10.

The browser reports the score, so it can't be proven. The endpoint doesn't pretend otherwise. It just keeps the data well-formed:

  • initials match ^[A-Z]{3}(&[A-Z]{3})?$
  • the score is a whole number of hundreds (every kill and power-up is worth hundreds), at most 9,999,900
  • the stage is between 1 and 50, and the difficulty is one the game has
  • each address may post 20 runs an hour

If the board is unreachable, the game just carries on without it.

NIGHTMARE: letting Jev command the enemies

The difficulty dial now has four settings:

  • EASY / NORMAL: the classic AI, with enemies that often (EASY) or sometimes (NORMAL) sit idle or head the wrong way.
  • HARD: the classic AI with no blunders. Enemies fire on sight after a human-like 300 ms reaction, take one extra hit, and you start in the level-2 tank.
  • NIGHTMARE: HARD's rules, with Jev deciding what each enemy is after.

Turn the dial to NIGHTMARE and the whole room turns blood red.

The FC Tank page with the difficulty dial on NIGHTMARE: the arcade room glows blood red around a CRT TV showing the TANK C 1990 title screen, with 1 PLAYER, 2 PLAYERS and FRIENDS PLAY above the EASY, NORMAL, HARD, NIGHTMARE row

Strategy from the model, driving from the engine

A model can't steer a tank 60 times a second, and it shouldn't. The game already has pathfinding and aiming that work. What the classic AI lacks is judgement. It mostly heads for the base or wanders, whatever is happening on the field.

So I split the job. Every 2 seconds the battle sends a snapshot to /api/enemy-guide, and Jev picks one objective per enemy tank:

  • get_power_up: drive to the power-up and grab it before a player does
  • hunt_player: chase the nearest player tank and shoot it
  • attack_base: go for the eagle
  • roam: flank, spread out, stay unpredictable

The tank's commander takes that objective as its route goal and re-plans when it changes. Everything frame by frame (turning, dodging walls, firing when a player lines up) is still the engine's job.

What Jev sees

The snapshot is plain numbers on the 13×13 tile grid:

{
  "base": { "x": 6, "y": 12 },
  "players": [
    { "id": "p1", "x": 4, "y": 2, "level": 2,
      "distance_to_nearest_enemy": 3, "distance_to_power_up": 6 }
  ],
  "power_ups": [{ "type": "clock", "x": 2, "y": 10 }],
  "enemies": [
    { "id": "e7", "type": "fish", "x": 5, "y": 0, "hp": 2,
      "distance_to_base": 13, "distance_to_nearest_player": 3,
      "distance_to_power_up": 13 }
  ]
}

The server adds the rules of the game, the enemy side's priorities (grab power-ups first, then hunt players, then attack the base when players are out of reach), and what each power-up does depending on who picks it up. A clock freezes the players if an enemy grabs it and freezes the enemies if a player does. That asymmetry is exactly what the model needs to judge whether a power-up is worth a detour.

Then it asks one multiple-choice question per tank, all in a single request:

questions[enemy.id] = {
  type: "choice",
  instructions:
    `Which objective should enemy tank \`enemies[${i}]\` pursue for the next few seconds ` +
    "so that the enemy side follows `enemy_priorities` and is most likely to win?",
  criteria: OBJECTIVES
};

Each answer comes back as a choice and a confidence, and the browser hands each choice to the matching tank. Because the answer is always one of four known strings, nothing the model says can make a tank do anything the game doesn't already support.

Making it robust

Most of the work wasn't the prompt. It was making sure a slow, failing or expensive remote call never hurts the game:

  • One question at a time. If an answer is still on its way when the next tick comes, the tick is skipped. Answers that arrive after a pause or the end of a stage are thrown away, because they describe a battlefield that no longer exists.
  • No retries. The client times out at 1.5 s and doesn't retry. The next tick, 2 seconds later, asks again with fresher information anyway.
  • Fail back to the classic AI. No answer, an error, or no API key: the tanks keep following their built-in rules. NIGHTMARE gets easier for a moment rather than freezing.
  • A daily budget. Every call is counted against 5,000 a UTC day in Redis, so the cap holds across all server instances. Past it, the endpoint answers 429 with Retry-After set to midnight UTC, and the browser drops every objective and stops asking until then.
  • The snapshot is rebuilt on the server. Anyone can POST to the endpoint, so the server rebuilds the snapshot field by field: numbers must be numbers, tank types must be short lowercase words, power-ups must be known types, and there are at most 20 tanks. Stray text never reaches the model.

Keeping it fair

The first version put Jev on NORMAL, and it was miserable. Enemies that grab every power-up and team up on you are much harder than the classic AI. Moving Jev to its own level wasn't enough on its own, either. I simulated duels with a 200–240 ms "player" reaction and found the player losing almost every face-to-face fight, for reasons that had nothing to do with AI:

  • When two missiles met head-on, the first to explode took out everything in its blast area, including the tank right behind the other missile. Head-on missiles now just cancel out.
  • Nothing limited the rate of fire, so after a cancel the enemy's zero-reaction AI always shot first. Tanks now reload between shots.

On top of that, player tanks move, shoot and reload 1.2× faster than enemies at every level. That's enough to win a one-on-one, not to take on three at once. On HARD, the simulated player now wins about 80% of one-on-ones.

The fairness feature I like best is hiding in grass. A player tank at least three quarters under grass can't be seen. Enemies won't shoot at it or hunt it, and Jev's snapshot reports that player as hidden_in_grass: true with no position at all. The server refuses a hidden player that comes with coordinates, so the model can't cheat even by accident. Against an enemy that thinks, ducking into the trees and letting them drive past is a real tactic.

The demo plays itself

Jev also drives the demo that plays when you leave the title screen alone. It picks an objective for the AI player tank: grab the power-up (a clock or land mine above all), hunt an enemy, or fall back to guard the base. Same endpoint, same budget, with the other side of the board.

An FC Tank battle on stage 31 in NIGHTMARE mode: the yellow player tank near the top of a maze of brick, steel, water and grass, two red enemy tanks closing in, a power-up on the field and the eagle base at the bottom; the side bar reads NIGHTMARE

What I took away

  • Mirroring beats a rewrite. Making the host authoritative and the guest a mirror got online co-op working without touching how the engine simulates anything.
  • Give the model decisions, not controls. Jev picks among four objectives every two seconds, and the existing engine does what it was already good at. It's cheap, it's safe, and it falls back cleanly.
  • Fairness is a gameplay problem, not an AI problem. The biggest improvements to NIGHTMARE were missile collisions, reload times and grass, not the prompt.

Go turn the dial to NIGHTMARE at fc-tank.bencao.it, or send a friend a link. The source is on GitHub.

No comments yet. Be the first.

Optional. Leave it blank to post as Anonymous.