From Contest to Crypto

Cassidy Rouse - November 1, 2018

If you’ve been following this week’s recap series, you’ll know that MapNYC was a contest to map New York City’s places. For those who didn’t participate, allow us to explain how it worked. New Yorkers downloaded our application and explored the city, adding points of interest (POIs). Participants received points for creating new POIs and validating (or rejecting inaccurate) POIs created by others. The top 100 point earners will receive prizes paid in Bitcoin, with the winner taking home an entire Bitcoin.

Here’s how the contest leaderboard played out in real-time:

.leaderboard__figure { --mapnyc-accent: #8428FF; line-height: 1.5; position: relative } .leaderboard__scroll { box-sizing: border-box; overflow: auto; height: 635px; padding-bottom: 1rem; } .leaderboard__figure::after { background: linear-gradient(rgba(255, 255, 255, 0), #fff); bottom: 0; content: ""; display: block; height: 30px; position: absolute; width: 100%; } @media (min-width: 31.25em) { .leaderboard__figure { float: left; z-index: 100; margin-right: 2em; min-width: 25rem; position: relative; } } .leaderboard { height: 93.75rem; width: 100%; } .leaderboard__controls { display: flex; } .position__border { fill: #dadada; } .position__placeholder { fill: #e5e5e5; transform: translate(90px, 1.3em); } .position__placeholder.points { transform: translate(0, 1.3em); width: 2rem; } .position__number { transform: translate(28px, 31px); } .position__number text { fill: var(--mapnyc-accent); font-size: 16px; font-weight: 400; text-anchor: middle; dominant-baseline: middle; transform: translate(0, 1px); } .position__number circle { fill: #fff; stroke: var(--mapnyc-accent); stroke-width: 2px; } .controls__btn { margin-right: 0.2rem; } .play-button { appearance: none; outline: none; border: 0; background: transparent; cursor: pointer; fill: var(--mapnyc-accent); opacity: 0.85; width: 60px; } .play-button svg { width: 100%; } .play-button:hover { opacity: 1; } .controls__active-date { align-items: center; display: flex; flex: 0 1 5.5em; font-size: 0.9rem; font-weight: 300; padding-left: 1rem; vertical-align: middle; } .controls__scrubber { align-items: center; display: flex; flex: 1 1 auto; } .scrubber { -webkit-appearance: none; background-color: transparent; width: 100%; } .scrubber:focus { outline: 1px solid rgba(132, 40, 255, 1); } .scrubber::-ms-track { width: 100%; cursor: pointer; background: transparent; border-color: transparent; color: transparent; } /* NOTE: We have to repeat the slider-thumb block for each vendor because we can't comma-separate these types of selectors.*/ .scrubber::-webkit-slider-thumb { background-color: #fff; border: 1px solid rgba(0, 0, 0, 0.1); border-radius: 0.8rem; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); cursor: pointer; display: block; height: 1.6rem; transform: scale3d(1, 1, 1); transition: 0.1s ease; width: 1.6rem; } .scrubber:focus::-webkit-slider-thumb, .scrubber::-webkit-slider-thumb:hover, .scrubber::-webkit-slider-thumb:hover:active { box-shadow: 0 3px 6px rgba(0, 0, 0, 0.3); transform: scale3d(1.4, 1.4, 1.4); } .scrubber::-webkit-slider-thumb { -webkit-appearance: none; margin-top: -0.7rem; } .scrubber::-moz-range-thumb { background-color: #fff; border: 1px solid rgba(0, 0, 0, 0.1); border-radius: 0.8rem; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); cursor: pointer; display: block; height: 1.6rem; transform: scale3d(1, 1, 1); transition: 0.1s ease; width: 1.6rem; } .scrubber:focus::-moz-range-thumb, .scrubber::-moz-range-thumb:hover, .scrubber::-moz-range-thumb:hover:active { box-shadow: 0 3px 6px rgba(0, 0, 0, 0.3); transform: scale3d(1.4, 1.4, 1.4); } .scrubber::-webkit-slider-runnable-track { background-color: rgba(0, 0, 0, 0.3); border-radius: 0.2rem; height: 0.3rem; cursor: pointer; padding: 0; width: 100%; } .scrubber:focus::-webkit-slider-runnable-track { background-color: rgba(0, 0, 0, 0.9); } .scrubber::-moz-range-track { background-color: rgba(0, 0, 0, 0.3); border-radius: 0.2rem; height: 0.3rem; cursor: pointer; padding: 0; width: 100%; } .scrubber:focus::-moz-range-track { background-color: rgba(0, 0, 0, 0.9); } .user__fill { fill: transparent; height: 60px; transform: translate(60px, 0); transition: 0.2s ease; transition-delay: 0.2s; width: calc(100% - 60px); } .user__change-container { transform: translate(70px, 1.3em); } .user__change { opacity: 0; transition: opacity 0.1s ease; /* NOTE: This is for Safari, it's...odd */ transform: translate(0, 0) rotate(0deg); } [data-change="gain"] { opacity: 1; fill: green; } [data-change="loss"] { opacity: 1; fill: red; transform: translate(12px, 0.7em) rotate(-180deg); } .user__name { dominant-baseline: hanging; fill: #000; transform: translate(90px, 1.2em); } .user__points { dominant-baseline: hanging; fill: #000; text-anchor: end; }

9/24/2018

(() => { class Leaderboard { constructor() { this.currentDay = 0; this.prevDay = null; this.dayDuration = 1000; this.shouldReset = false; this.fetchData(); } async fetchData() { const data = await d3.json( "https://s3.amazonaws.com/assets.streetcred.co/data/leaderboard.json" ); this.dates = Object.keys(data); this.data = this.dates.map(day => data[day]); this.render(); } setDayDisplay() { const nextDate = new Date(parseInt(this.dates[this.currentDay], 10)); d3.select(".controls__active-date").text( nextDate.toLocaleDateString("en-US") ); } tick(day, prevDay = null) { this.prevDay = day; this.setDayDisplay(); if (day === 0) { this.svg.selectAll("g.user").remove(); } const board = this.data[day]; const prevBoard = this.data[prevDay || day - 1] || []; const transY = i => `translate(0, ${60 * i})`; // Clean up. Each time we get a new board, check to see which users were // on the previous board that are not on today's board then remove those // users' svg element. const pluckIds = u => u.user_id; const userIndex = board.map(pluckIds); const prevUserIndex = prevBoard.map(pluckIds); const toRemove = prevUserIndex.filter(u => !userIndex.includes(u)); toRemove.forEach(id => d3.select(`.user-${id}`).remove()); board.forEach(({ user_id, username, points }, i) => { const className = `user-${user_id}`; const el = d3.select(`.${className}`); // If this user already has a representing element, update it. if (el.size()) { const prevPos = parseInt(el.attr("data-position")); el.transition() .duration(250) .attr("transform", transY(i)) .attr("data-position", i); const change = prevPos > i ? "gain" : "loss"; el.select(".user__change").attr( "data-change", prevPos === i ? null : change ); el.select(".user__points") .transition() .duration(500) .tween("text", function(d) { const i = d3.interpolateRound( parseInt(this.textContent.replace(/\,/g, ""), 10), points ); return t => (this.textContent = d3.format(",")(i(t))); }); } else { // If not, create a new one. const g = this.svg .append("g") .attr("class", `user ${className}`) .attr("data-position", i) .attr("transform", transY(i)); g.append("rect").attr("class", "user__fill"); const triSize = 12; g.append("g") .attr("class", "user__change-container") .append("polyline") .attr("class", "user__change") .attr( "points", `${triSize / 2},0 0,${triSize} ${triSize},${triSize}` ); g.append("text") .attr("class", "user__name") .text(username || "n/a"); g.append("text") .attr("class", "user__points") .attr("x", "95%") .attr("y", "1.2em") .text(points); } }); if (board.length === 0) { this.drawPlaceholders(); } else { d3.selectAll(".position__placeholder").remove(); } } pause() { if (this.playInterval) { clearInterval(this.playInterval); this.playInterval = undefined; } this.togglePlayBtn(false); } play() { if (this.shouldReset) { this.svg.selectAll("g.user").remove(); this.shouldReset = false; this.setDayDisplay(); } this.playInterval = setInterval(() => { this.tick(this.currentDay); this.scrubber.property("value", this.currentDay); // Pause when we get to the last day and prepare for reset. if (this.currentDay + 1 === this.data.length) { this.pause(); this.currentDay = 0; this.shouldReset = true; } else { this.currentDay = this.currentDay === this.data.length - 1 ? 0 : this.currentDay + 1; } }, this.dayDuration); this.togglePlayBtn(true); } togglePlayBtn(playing) { const btnPause = "M11,10 L18,13.74 18,22.28 11,26 M18,13.74 L26,18 26,18 18,22.28"; const btnPlay = "M11,10 L17,10 17,26 11,26 M20,10 L26,10 26,26 20,26"; const btnAnimation = d3.select("#play-button__animation"); btnAnimation.attr("from", playing ? btnPause : btnPlay); btnAnimation.attr("to", playing ? btnPlay : btnPause); btnAnimation.node().beginElement(); } initBoard() { const positionHeight = 60; const yPos = (d, index) => positionHeight * (index + 1); this.svg = d3.select(".leaderboard"); const positions = this.svg .selectAll(".position") .data(new Array(25)) .enter() .append("g") .attr( "transform", (d, index) => `translate(0, ${positionHeight * index})` ) .attr("class", "position"); const number = positions.append("g").attr("class", "position__number"); number.append("circle").attr("r", 16); number.append("text").text((d, i) => i + 1); // Bottom border. positions .append("rect") .attr("class", "position__border") .attr("width", "100%") .attr("height", 1) .attr("transform", "translate(0, 59)"); this.drawPlaceholders(); } drawPlaceholders() { d3.selectAll(".position__placeholder").remove(); const positions = d3.selectAll(".position"); positions .append("rect") .attr("width", "60%") .attr("height", "1em") .attr("class", "position__placeholder"); positions .append("rect") .attr("x", "92%") .attr("height", "1em") .attr("width", "2em") .attr("class", "position__placeholder points"); } render() { this.initBoard(); d3.select(".play-button").on("click", () => { if (this.playInterval) { this.pause(); } else { this.play(); } }); const that = this; const scrubber = d3.select(".scrubber"); scrubber .on("input", function() { const day = parseInt(this.value, 10); that.currentDay = day; that.tick(day, that.prevDay); }) .attr("max", this.data.length - 1); this.scrubber = scrubber; this.togglePlayBtn(false); this.play(); } } document.addEventListener("DOMContentLoaded", () => { new Leaderboard(); }); })();

But what does a contest have to do with building a blockchain protocol? And why not just pay users a fixed amount of Bitcoin for every POI created and validated? We’re glad you asked.

A contest allowed us to observe behavior and gather data in an environment shaped by competition and incentives - similar to a decentralized protocol. For example, we didn’t know how much Bitcoin to pay users in exchange for creating or validating a POI. We also didn’t know how many POIs a New Yorker could map in a typical day. A contest structure places these unknown variables in the hands of a competitive market, allowing us to observe and react. We gathered invaluable data points that we’re already using to sharpen our protocol and token design.

Designing contest incentives is hard. Leading fantasy sports, eSports, and gaming companies have invested heavily in algorithms that dynamically create optimal payout structures. So, we dusted off our behavioral economics textbooks and took our best shot. The chart below outlines our Bitcoin payouts for the top 100 participants.

Bitcoin Payouts

Top 100 Users

We designed this structure to achieve two goals: 1) maintain light competition at the low end, making it appealing for new users to enter; and 2) encourage stronger competition among the leaders, where even slight jumps on the leaderboard could result in significant changes in payout.

Our incentive model worked. Not only did we observe a flurry of activity among the top 10 users throughout the contest (see dynamic leaderboard above), but any new user could enter the contest and make it on the leaderboard by creating or validating a handful of places. One particular user entered the contest with only three days to go, went on a weekend mapping frenzy, and reached the top 15, earning 0.16 Bitcoin (~$1,000).

The chart below outlines how total user activity (POI creations and validations) aligned with Bitcoin payouts for the top 100 participants. You’ll see there was very strong relationship between Bitcoin payout and total activity. Bottom line: financial incentives matter.

User Activity vs Bitcoin Payout

Top 100 Users

Note: A participant can have less activity than another yet score more points. This is due to point bonuses that were offered for certain categories and geographic areas.

.chart-title { margin: 0.5rem 0 0 0; } .chart-subtitle { font-weight: 300; margin: 0 0 1rem 0; } (() => { const payoutData = [ {x:1 , y: 1.00}, {x:2 , y: 0.80}, {x:3 , y: 0.64}, {x:4 , y: 0.48}, {x:5 , y: 0.28}, {x:6 , y: 0.22}, {x:7 , y: 0.21}, {x:8 , y: 0.19}, {x:9 , y: 0.18}, {x:10 , y: 0.16}, {x:11 , y: 0.16}, {x:12 , y: 0.16}, {x:13 , y: 0.16}, {x:14 , y: 0.16}, {x:15 , y: 0.16}, {x:16 , y: 0.14}, {x:17 , y: 0.14}, {x:18 , y: 0.14}, {x:19 , y: 0.14}, {x:20 , y: 0.14}, {x:21 , y: 0.08}, {x:22 , y: 0.08}, {x:23 , y: 0.08}, {x:24 , y: 0.08}, {x:25 , y: 0.08}, {x:26 , y: 0.08}, {x:27 , y: 0.08}, {x:28 , y: 0.08}, {x:29 , y: 0.08}, {x:30 , y: 0.06}, {x:31 , y: 0.06}, {x:32 , y: 0.06}, {x:33 , y: 0.06}, {x:34 , y: 0.06}, {x:35 , y: 0.06}, {x:36 , y: 0.06}, {x:37 , y: 0.06}, {x:38 , y: 0.06}, {x:39 , y: 0.06}, {x:40 , y: 0.06}, {x:41 , y: 0.04}, {x:42 , y: 0.04}, {x:43 , y: 0.04}, {x:44 , y: 0.04}, {x:45 , y: 0.04}, {x:46 , y: 0.04}, {x:47 , y: 0.04}, {x:48 , y: 0.04}, {x:49 , y: 0.04}, {x:50 , y: 0.04}, {x:51 , y: 0.02}, {x:52 , y: 0.02}, {x:53 , y: 0.02}, {x:54 , y: 0.02}, {x:55 , y: 0.02}, {x:56 , y: 0.02}, {x:57 , y: 0.02}, {x:58 , y: 0.02}, {x:59 , y: 0.02}, {x:60 , y: 0.02}, {x:61 , y: 0.01}, {x:62 , y: 0.01}, {x:63 , y: 0.01}, {x:64 , y: 0.01}, {x:65 , y: 0.01}, {x:66 , y: 0.01}, {x:67 , y: 0.01}, {x:68 , y: 0.01}, {x:69 , y: 0.01}, {x:70 , y: 0.01}, {x:71 , y: 0.01}, {x:72 , y: 0.01}, {x:73 , y: 0.01}, {x:74 , y: 0.01}, {x:75 , y: 0.01}, {x:76 , y: 0.01}, {x:77 , y: 0.01}, {x:78 , y: 0.01}, {x:79 , y: 0.01}, {x:80 , y: 0.01}, {x:81 , y: 0.01}, {x:82 , y: 0.01}, {x:83 , y: 0.01}, {x:84 , y: 0.01}, {x:85 , y: 0.01}, {x:86 , y: 0.01}, {x:87 , y: 0.01}, {x:88 , y: 0.01}, {x:89 , y: 0.01}, {x:90 , y: 0.01}, {x:91 , y: 0.01}, {x:92 , y: 0.01}, {x:93 , y: 0.01}, {x:94 , y: 0.01}, {x:95 , y: 0.01}, {x:96 , y: 0.01}, {x:97 , y: 0.01}, {x:98 , y: 0.01}, {x:99 , y: 0.01}, {x:100, y: 0.01} ]; const activityData = [ { x: "1" , y: 5826}, { x: "2" , y: 5408}, { x: "3" , y: 4609}, { x: "4" , y: 2555}, { x: "5" , y: 2472}, { x: "6" , y: 1391}, { x: "7" , y: 1005}, { x: "8" , y: 1811}, { x: "9" , y: 1538}, { x: "10" , y: 1400}, { x: "11" , y: 856}, { x: "12" , y: 882}, { x: "13" , y: 862}, { x: "14" , y: 793}, { x: "15" , y: 656}, { x: "16" , y: 973}, { x: "17" , y: 425}, { x: "18" , y: 689}, { x: "19" , y: 804}, { x: "20" , y: 691}, { x: "21" , y: 590}, { x: "22" , y: 639}, { x: "23" , y: 722}, { x: "24" , y: 513}, { x: "25" , y: 733}, { x: "26" , y: 568}, { x: "27" , y: 344}, { x: "28" , y: 535}, { x: "29" , y: 348}, { x: "30" , y: 469}, { x: "31" , y: 441}, { x: "32" , y: 279}, { x: "33" , y: 407}, { x: "34" , y: 382}, { x: "35" , y: 327}, { x: "36" , y: 372}, { x: "37" , y: 306}, { x: "38" , y: 405}, { x: "39" , y: 345}, { x: "40" , y: 301}, { x: "41" , y: 334}, { x: "42" , y: 315}, { x: "43" , y: 239}, { x: "44" , y: 262}, { x: "45" , y: 271}, { x: "46" , y: 158}, { x: "47" , y: 292}, { x: "48" , y: 261}, { x: "49" , y: 240}, { x: "50" , y: 258}, { x: "51" , y: 219}, { x: "52" , y: 233}, { x: "53" , y: 265}, { x: "54" , y: 218}, { x: "55" , y: 138}, { x: "56" , y: 138}, { x: "57" , y: 191}, { x: "58" , y: 114}, { x: "59" , y: 199}, { x: "60" , y: 187}, { x: "61" , y: 161}, { x: "62" , y: 91}, { x: "63" , y: 113}, { x: "64" , y: 139}, { x: "65" , y: 137}, { x: "66" , y: 178}, { x: "67" , y: 128}, { x: "68" , y: 99}, { x: "69" , y: 82}, { x: "70" , y: 79}, { x: "71" , y: 88}, { x: "72" , y: 94}, { x: "73" , y: 83}, { x: "74" , y: 87}, { x: "75" , y: 41}, { x: "76" , y: 72}, { x: "77" , y: 74}, { x: "78" , y: 43}, { x: "79" , y: 59}, { x: "80" , y: 59}, { x: "81" , y: 51}, { x: "82" , y: 31}, { x: "83" , y: 55}, { x: "84" , y: 49}, { x: "85" , y: 46}, { x: "86" , y: 23}, { x: "87" , y: 41}, { x: "88" , y: 44}, { x: "89" , y: 64}, { x: "90" , y: 104}, { x: "91" , y: 27}, { x: "92" , y: 47}, { x: "93" , y: 32}, { x: "94" , y: 34}, { x: "95" , y: 30}, { x: "96" , y: 31}, { x: "97" , y: 32}, { x: "98" , y: 32}, { x: "99" , y: 30}, { x: "100", y: 27} ]; const labels = []; for(let i=1; i<=100; i++) labels.push(i); document.addEventListener("DOMContentLoaded", () => { const activityChart = new Chart('activity-chart', { type: 'bar', data: { labels, datasets: [ { type: "line", data: payoutData, label: "Bitcoin Payouts", borderColor: "#8428FF", backgroundColor: "transparent", fill: true, yAxisID: 'right-axis', }, { data: activityData, label: "User Transactions", borderColor: "#ff2828", borderWidth: 1, backgroundColor: "#ff2828", fill: true, yAxisID: 'left-axis' } ] }, options: { responsive: true, elements: { point: { radius: 0 }, line: { tension: 0, // disables bezier curves } }, legend: { display: true, position: "bottom" }, scales: { xAxes: [{ display: true, scaleLabel: { display: true, labelString: 'Leaderboard Position' }, ticks: { maxTicksLimit: 5, stepSize: 20.0, min: 0, max: 100, beginAtZero: true } }], yAxes: [ { position: 'left', id: 'left-axis', display: true, scaleLabel: { display: true, labelString: 'User Transactions' }, ticks: { maxTicksLimit: 5, stepSize: 2000, min: 0, max: 6000 } }, { position: 'right', id: 'right-axis', display: true, scaleLabel: { display: true, labelString: 'Reward (Bitcoin)' }, ticks: { maxTicksLimit: 5, stepSize: 0.25, min: 0, max: 1 } } ] } } }); const payoutChart = new Chart('payout-chart', { type: 'line', data: { labels, datasets: [{ data: payoutData, label: "Bitcoin Payouts", borderColor: "#8428FF", backgroundColor: "#8428FF60", fill: true }] }, options: { responsive: true, elements: { point: { radius: 0 }, line: { tension: 0, // disables bezier curves } }, legend: { display: false}, scales: { xAxes: [{ display: true, scaleLabel: { display: true, labelString: 'Leaderboard Position' }, ticks: { maxTicksLimit: 5, stepSize: 20.0, min: 1, max: 100 } }], yAxes: [{ display: true, scaleLabel: { display: true, labelString: 'Reward (Bitcoin)' }, ticks: { maxTicksLimit: 5, stepSize: 0.25, min: 0, max: 1 } }] } } }); }); })();

Looking Ahead

The contest produced additional insights that we’re now using to guide our protocol and token design.

Incentives. The total prize pool for MapNYC was 8 Bitcoin ($50,000). For those receiving a Bitcoin prize, the average reward per action (i.e. creating or validating a POI) was approximately 0.0002 Bitcoin ($1.00). And because we required multiple users to validate a POI before it was accepted, our total blended cost to create and fully validate a POI was approximately 0.0004 Bitcoin (~$2.40). All interesting data points, but what does this all mean in a future protocol powered by our own token?

It’s clear that our users cared about real financial incentives. But many also viewed their work on the protocol as an investment. For example, when we asked what Bitcoin winners planned to do with their reward, nearly 50% plan to hold it. This creates real opportunity to introduce a token that can both incentivize participation and also grow with the commercial value of our data, enabling our protocol to scale more economically and our community to share in the upside of the value we create together. Lots more to share here but probably best discussed over a coffee.

User productivity. We believe that decentralization benefits from a broad and diverse user base, and our MapNYC user activity reflected this. Casual mappers typically added or validated fewer than 10 POIs / day, contrasted with an average of 100 POIs / day for our top 10 users. The winner mapped over 200 POIs / day. This distribution creates a natural segmentation on our protocol, where anyone can contribute data (thus increasing coverage and diversity) but some users choose to invest more heavily by bonding tokens in exchange for special rights (thus increasing accuracy and integrity).

MapNYC was a targeted experiment. We showed what a handful of users - some deeply engaged, some passively interested - can accomplish with the right incentives in place. As our app and protocol become more widely available, these productivity metrics scale exponentially. It’s not unrealistic to envision a living, breathing system capable of mapping entire cities in a matter of days.