📄 Race to the Center - README.txt
×

Race to the Center

PoliticsSocial Science CanvasRendererLineChartRenderer

This model illustrates the median voter theorem, a foundational result in political economy. Voters are distributed along a one-dimensional ideological spectrum, and two candidates compete for their votes. Each voter supports whichever candidate is ideologically closer; the candidate with fewer votes then adjusts their position toward the center to capture more of the electorate.

Under these conditions, both candidates converge toward the median voter's position—the "race to the center." With a high LEARNING_RATE, convergence is rapid and deterministic. With a lower rate, candidates explore suboptimal positions before settling, and the trajectory resembles a random walk. The model captures a basic tension in democratic politics: electoral incentives push candidates toward positions that maximize votes, often at the expense of ideological distinctiveness. Extensions to multiple dimensions or more than two candidates complicate this picture considerably.

💻 race-to-the-center.js - Interactive Editor
×
import { Agent, Environment, CanvasRenderer, utils, LineChartRenderer } from "flocc";

const width = window.innerWidth;
const height = 80;
const POPULATION = 201;

let LEARNING = true;
let LEARNING_RATE = 0.4;

let left;
let right;

const environment = new Environment({
  width,
  height,
  torus: false
});
const renderer = new CanvasRenderer(environment, {
  background: "#eee",
  width,
  height
});
renderer.mount("#container");

const chart = new LineChartRenderer(environment, {
  autoScale: true,
  height: 250,
  width: width / 2,
  range: {
    max: POPULATION,
    min: 0
  }
});
chart.metric("left", {
  color: "blue",
  fn: utils.sum
});
chart.metric("right", {
  color: "red",
  fn: utils.sum
});
chart.mount("#line");

const distance = new LineChartRenderer(environment, {
  autoScale: true,
  height: 250,
  width: width / 2,
  range: {
    max: width / 2,
    min: 0
  }
});
distance.metric("left", {
  color: "blue",
  fn() {
    return Math.abs(width / 2 - left.get("x"));
  }
});
distance.metric("right", {
  color: "red",
  fn() {
    return Math.abs(width / 2 - right.get("x"));
  }
});
distance.mount("#distance");

function vote(agent) {
  let choice;
  const dl = utils.distance(agent, left);
  const dr = utils.distance(agent, right);
  if (dl < dr) {
    choice = left;
  } else if (dl > dr) {
    choice = right;
  } else {
    choice = utils.sample([left, right]);
  }
  choice.increment("votes");
  return {
    left: choice === left ? 1 : 0,
    right: choice === right ? 1 : 0
  };
}

function shift(agent) {
  const { votes } = agent.getData();
  if (votes < POPULATION / 2) {
    agent.increment("x", agent.get("direction"));
    // prevent ideological crossover
    // or going out of ideological bounds
    const min = agent.get("color") === "blue" ? 0 : width / 2;
    const max = agent.get("color") === "blue" ? width / 2 : width;
    agent.set("x", utils.clamp(agent.get("x"), min, max));

    if (LEARNING) {
      let choices = [1, -1];
      if (LEARNING_RATE === 1) {
        choices = [agent.get("lastVotes") > votes ? -1 : 1];
      } else {
        for (let i = LEARNING_RATE; i > 0; i -= 0.1) {
          choices.push(agent.get("lastVotes") > votes ? -1 : 1);
        }
      }
      agent.set("direction", agent.get("direction") * utils.sample(choices));
    } else if (!LEARNING) {
      agent.set("direction", utils.sample([1, -1]));
    }

    agent.set("lastVotes", votes);
  }

  return { votes: 0 };
}

function setup() {
  for (let i = 0; i < POPULATION; i++) {
    let x;
    do {
      x = utils.gaussian(width / 2, width / 4);
    } while (x < 0 || x > width);
    environment.addAgent(
      new Agent({
        x,
        y: height / 2,
        size: 1,
        tick: vote
      })
    );
  }
  left = new Agent({
    x: utils.random(0, width / 4),
    y: height / 2,
    size: 8,
    color: "blue",
    votes: 0,
    direction: utils.sample([1, -1]),
    tick: shift
  });
  environment.addAgent(left);
  right = new Agent({
    x: utils.random((3 * width) / 4, width),
    y: height / 2,
    size: 8,
    color: "red",
    votes: 0,
    direction: utils.sample([1, -1]),
    tick: shift
  });
  environment.addAgent(right);
}

function run() {
  environment.tick();
  if (environment.time < 2500) requestAnimationFrame(run);
}

setup();
run();

Edit the code on the left · See results on the right